Accélérer WordPress : le serveur HTTP

Dans cet article, nous allons nous pencher sur les entêtes d’expiration, la compression gzip et surtout sur le service conditionnel d’images Webp.

(À peu près tout ce qui est décrit dans cet article est applicable à n’importe quelle appli web.)

Maintenant qu’on a un WordPress qui rame pas trop, on va configurer au mieux ce qu’il y a devant : le serveur HTTP.

Jusqu’ici, le plus simple pour déployer un WordPress, c’est la bonne vieille stack LAMP (Linux-Apache-Mysql-PHP). Cependant, Apache avec mod-php, si c’est la solution la plus simple, et probablement la mieux documentée partout sur internet, n’est largement pas la plus efficace, pour plusieurs raisons, comme par exemple :

  • mod-php instancie un interpréteur PHP par requête, ce qui ajoute de l’overhead
  • Apache2 a des fonctionnalités sympa mais peu performantes, comme la gestion des options via .htaccess. C’est désactivable, mais cela complique les choses ailleurs.

On va donc servir notre appli avec nginx, en utilisant le daemon php-fpm. Celui-ci tourne en permanence et est, du coup, toujours prêt et dispo à servir un script php.

La conf de nginx est très différente de celle d’Apache2, mais elle est très bien documentée aussi et l’on peut trouver des exemples un peu partout. Celui-ci est très bien pour la base : Installation de WordPress avec nginx.

Il y manque des morceaux assez importants cependant.

Le cache côté client

Tout d’abord, autant les pages de l’application sont tout à fait dynamiques et doivent être regénérées avant d’être envoyées (ce n’est pas tout à fait vrai, nous verrons cela dans une troisième partie dédiée au cache serveur), autant les fichiers qui ne changent « jamais », tels que les images, les scripts JS ou encore les CSS, … on n’a pas besoin de les renvoyer à un·e internaute d’une page à l’autre, n’est-ce pas ?

Pour cela, on va configurer nginx pour indiquer au navigateur que ces fichiers ont une longue durée de validité. À la suite de quoi, le navigateur ne re-contactera même pas le serveur pour les re-télécharger lors de la visite d’une deuxième, troisième page.

location ~* \.(js|css|gif|jpg|jpeg|png|ico|ttf|woff|woff2)$ {
    expires max;
    log_not_found off;
}

Ces quatre lignes indiqueront à nginx d’envoyer avec la réponse à une requête pour tout fichier dont l’extension est dans la liste les entêtes suivants :

cache-controlmax-age=315360000
expiresThu, 31 Dec 2037 23:55:55 GMT

Cela indique au navigateur de ne pas réessayer de recharger le fichier avant 2037, … ce qui laissera du temps à nginx pour servir autre chose !

Cependant, qu’arrivera-t’il si vous décidez de modifier le style de votre site, d’ajouter une fonctionnalité dans un javascript ? Il faut bien que les navigateurs aient vos changements avant 2037… La solution la plus simple pour cela est de toujours suffixer les URLs de ressources statiques avec un paramètre : ?v=1.1.0, ?mod=1645556315, un hash du fichier, etc. À vous de choisir. Dans l’idéal, il faut une solution simple pour pouvoir incrémenter ce paramètre de la manière la plus automatique possible. Dans le cas de WordPress, c’est simple : Il le fait lui-même !

La compression

Maintenant qu’on s’économise environ 90% de requêtes avec le cache client, on va être le plus doux possible avec sa bande passante. Cela permettra à chaque requête de se terminer plus rapidement, surtout pour les internautes avec des connexions moyennes. Pour cela, il suffit d’activer la compression sur nginx :

gzip_comp_level 6;
gzip_min_length 1100;
gzip_buffers 16 8k;
gzip_proxied any;
gzip_types
    text/plain
    text/css
    text/js
    text/xml
    text/javascript
    application/javascript
    application/x-javascript
    application/json
    application/xml
    application/rss+xml
    image/svg+xml;

gzip_disable "msie6";

Cela indique à nginx de compresser (niveau 6, un bon compromis entre le temps CPU que cela prend au serveur et le gain de taille) tout ce qui fait plus de 1100 octets (pourquoi 1100 octets ? Car avec les entêtes de réponse HTTP, ça tient dans un paquet TCP), pour tous les types MIME listés.

En souvenir du bon vieux temps, on désactive la compression pour Internet Explorer 6, qui ne la gère pas. (mais très honnêtement, il gère pas grand chose, bonne chance pour faire un site lisible par IE6, tout le monde s’en cogne, qu’il pourrisse en enfer).

On reloade la configuration d’nginx avec sudo nginx -t && sudo nginx -s reload, et :

On voit* que l’on a gagné environ 250ko sur le total transféré. C’est un petit pourcentage dans cet exemple où l’on sert pas mal d’images, ainsi qu’une police de caractères au format woff2. Ces fichiers là étant déjà dans un format compressé, rien ne sert de les re-compresser.

(* on voit mal, car Firefox a décidé de mettre les colonnes Transferred size / size dans un ordre donné, et les totaux en bas dans l’ordre inverse, size / transferred size. Mais on voit, quand même).

Les images, et Webp

Malgré cela, on peut quand même gagner pas mal sur la compression des images. Les conseils habituels s’appliquent : uploadez des jpeg compressés raisonnablement pour ne pas trop perdre en qualité. Vous pouvez aussi uploader des png pour les images qui nécessitent de gérer la transparence, ou pour une compression sans perte (lossless). Uploadez des images dont vous aurez préalablement enlevé les infos EXIF (non seulement pour le poids, mais aussi pour la vie privée, elles peuvent contenir des coordonnées GPS si l’appareil de prise de vue le gère).

Cependant un nouveau format d’image, dédié au web, a fait son apparition il y a quelques années : le webp. Ce format est fait exprès pour le web et amène un gain d’environ 25-30% sur la taille des images. Mais il y a un hic : tous les navigateurs ne le gèrent pas encore ! Il faut donc configurer le serveur web pour ne les renvoyer qu’aux navigateurs qui le gèrent.

Pour éviter de devoir uploader dans deux formats différents, ce qui serait extrêmement pénible, nous allons, dans un premier temps, écrire un script qui va se charger de trouver tous les png et jpg récemment uploadés, et de les recompresser en webp :

#!/bin/sh

#Vérifions si le fichier de lock est présent
if [ -f /tmp/convert-running ]; then
        #Si c'est le cas, le script tourne déjà. Laissons le faire.
        exit 0
fi

#Si le script s'arrête brutalement, supprimons le fichier de lock
trap "rm -f /tmp/convert-running" TERM

#Posons le fichier de lock
touch /tmp/convert-running

#Paramètre optionnel pour lancer la conversion initiale de toutes les 
#images, y compris les vieilles
if [ "$1" = "full" ]; then
        #10000 jours, ça fait 27 ans
        mtime="-10000"
else
        #Par défaut, on ne cherchera que dans les fichiers arrivés
        #dans les deux derniers jours
        mtime="-2"
fi

#On positionne le séparateur de champ à <nouvelle ligne>,
#pour les images dont le nom contiendrait une espace
IFS='
'

#on se positionne dans le dossier où arrivent les images
#uploadées par l'interface d'administation de WordPress
cd ~/www/wordpress/wp-content/uploads

#On cherche tous les fichiers modifiés il y a moins de deux jours,
#il y a plus de deux minutes (afin d'éviter de traiter un fichier
#en cours d'upload), et dont le nom finit par une extension 
#d'image jpg ou png
FILES=`find . -maxdepth 1 -mtime $mtime -mmin +2 \( -name "*jpg" -o -name "*JPG" -o -name "*jpeg" -o -name "*JPEG" -o -name "*png" -o -name "*PNG" \)`

#Pour chacun d'entre eux,
for i in $FILES; do

        #s'il existe, mais qu'il n'existe pas de fichier
        #avec le même nom et l'extension .webp en suffixe,
        if [ -f "$i" -a ! -f "$i.webp" ]; then

                #On l'enregistre au format webp avec Convert,
                #outil fourni par imagemagick, avec le flag -strip
                #pour en profiter pour supprimer les infos EXIF 
                #éventuelles.
                convert -strip "$i" "$i.webp"
        fi
done

#On a fini, on enlève le fichier de lock
rm -f /tmp/convert-running

On lance ce script une première fois pour traiter toutes les images pré-existantes, avec l’utilisateur ayant les droits sur le répertoire d’upload de wordpress (ici, www-data) :

sudo -u www-data /usr/local/bin/web-convert full

Puis on met ce script en crontab de l’utilisateur ayant les droits adaptés pour qu’il soit exécuté toutes les minutes : sudo crontab -e -u www-data

* * * * * /usr/local/bin/webp-convert

Nous avons maintenant nos images dans les deux formats, elles seront automatiquement transformées dans la minute à chaque upload, et nous pouvons déjà juger de la différence de poids de chaque type d’image dans notre répertoire d’upload :

$ for type in jpg jpg.webp png png.webp; do \
    echo -n "${type} : "; \
    du -ch *.${type} | tail -1; \
  done
jpg :      4.8G	total
jpg.webp : 3.1G	total
png :      322M total
png.webp : 58M	total

Il nous reste… à savoir quel format servir à quel navigateur, histoire de ne pas envoyer un webp tout moderne à un vieux Safari qui ne saurait qu’en faire.

Par chance, les navigateurs envoient, avec leurs requêtes, l’entête Accept, qui leur permet de spécifier quels formats pas encore implémentés partout ils peuvent comprendre. Un navigateur moderne, par exmple, enverra : Accept: image/avif,image/webp,*/*

Prenons parti de cet entête pour indiquer à nginx de servir l’image en webp lorsque le navigateur l’accepte… et qu’elle existe sur le disque.

Commençons par indiquer à nginx de régler une variable, $webp_suffix, à « .webp » quand le navigateur accepte le webp, et à «  » (vide) sinon, en créant un fichier /etc/nginx/conf.d/webp.conf :

map $http_accept $webp_suffix {
    default "";
    "~*webp" ".webp";
}

Reprenons la directive de cache que nous avons vu plus haut, et séparons le traitement des jpg et png :

location ~* \.(js|css|gif|ico|ttf|woff|woff2)$ {
    expires max;
    log_not_found off;
}

location ~* \.(png|jpg|jpeg)$ {
    expires max;
    log_not_found off;
    add_header "Vary" "Accept";
    try_files $uri$webp_suffix $uri =404;
}

La ligne add_header "Vary" "Accept" demande à nginx d’indiquer que le contenu renvoyé par cette URL sera différent en fonction de l’entête de requête Accept envoyé par le navigateur. Pour un·e internaute donné·e, il ne sert à rien, mais pour les internautes derrière un proxy, il permettra de dire au proxy qu’il peut y avoir plusieurs réponses différentes à cette même URL.

La ligne try_files, elle, indique à nginx d’essayer de servir le fichier correspondant à $uri$webp_suffix dans un premier temps. Si le navigateur en face ne gère pas le format, $webp_suffix sera vide et nginx trouvera et renverra le jpg/png originel.

Mais si notre script de conversion n’a pas encore tourné à la première requête à ce fichier (et cela sera le cas, l’image étant affichée directement après upload dans l’administration de WordPress…) ? le deuxième essai de la ligne try_files sera $uri directement, et nous nous verrons donc servir le fichier originel en attendant que le script de conversion aie tourné. Enfin, en dernier recours, on renverra une 404.

Et voilà,