Accélérer WordPress, partie 3 : Varnish

Le truc avec un blog – et pas seulement – c’est qu’on peut faire des sites dynamiques, dont on édite le contenu sans toucher à des fichiers et qui peuvent être mis à jour dans certaines conditions par les internautes même.

Pour arriver à ce confort, plein d’informations sont stockées en base de données, et sont mises en forme par un logiciel ou une pile de logiciel.

Par exemple, pour afficher une page sur ce blog, WordPress fait entre 3 et 10 requêtes à la base de données, assemble entre 3 et 6 templates, traduit 3000 chaînes de caractères, pour, la plupart du temps, réafficher exactement la même page qu’à la visite précédente.

À quoi bon la recalculer à chaque fois ? Il existe des plugins WordPress qui mettent en cache les pages générées, mais je ne m’étendrai pas dessus car cela reste très loin d’être optimal (mettant en jeu nginx, php, et wordpress).

À la place, je vais installer Varnish. C’est un reverse proxy qui demande les pages au serveur, puis, selon certains critères, les met en cache. Lors de la requête suivante à la même page, il lui suffit de 12 syscalls pour la resservir à partir de la mémoire. C’est l’une des solutions de cache les plus performantes.

Notre stack actuelle ressemble à ceci :

Nginx écoute sur les ports 443 et 80 => Nginx transfère la requête à php-fpm => php-fpm la traite avec WordPress.

On veut Varnish devant Nginx, malheureusement, Varnish ne gère pas le HTTPS. Je dis malheureusement, mais c’est un choix conscient de l’équipe de Varnish qui considère que ce n’est pas le travail de leur logiciel, et c’est un bon choix selon moi : un logiciel pour une chose. Nous allons donc continuer à utiliser Nginx en frontal. Il ira demander la page à Varnish. Celui-ci ira demander la page à … Nginx, mais en clair sur le port 8080. Puis php-fpm et WordPress :

Nginx(443 et 80) => Varnish (6081) => Nginx (8080) => php-fpm => WordPress

Voici donc la configuration de nginx :

#On déclare l'upstream Varnish
upstream varnish {
    keepalive 100;
    server 127.0.0.1:6081;
}

#On déclare l'upstream php-fpm
upstream php_www {
    server unix:/var/run/php/php8.1-fpm.sock;
}

#Le frontal HTTP (port 80)
server {
    listen 80;
    listen [::]:80;

    server_name www.colino.net;

    location / {
        #On transfère à Varnish
        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_pass http://varnish;
    }
}

#Le serveur HTTPS (port 443)
server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;

    server_name www.colino.net;

    #Configuration du certificat
    ssl_certificate /etc/letsencrypt/live/colino.net/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/colino.net/privkey.pem;

    location / {
        #On transfère à Varnish, en ajoutant deux headers pour marquer
        #le fait qu'on vient de l'endpoint HTTPS.
        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_set_header X-Is-Secure true;
        proxy_set_header X-Forwarded-Proto https;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_pass http://varnish;
    }

    #Ajout du header HSTS
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains";
}

#Le serveur applicatif, que Varnish viendra interroger
server {
    listen 127.0.0.1:8080 default_server;
    listen [::1]:8080 default_server;

    server_name colino.net www.colino.net;

    #Pas besoin de re-logguer la requête, elle sera logguée
    #par les reverse-proxies sur les ports 443/80.
    access_log off;

    root /var/www;

    #Est-ce qu'on a l'entête indiquant que la requête est arrivée sur
    #le nginx HTTPS ? Si non, on renvoie vers là.
    set $should_redirect_https 0;
    if ($http_x_is_secure != "true") {
        set $should_redirect_https 1;
    }
    if ($should_redirect_https = 1) {
        return 301 https://$server_name$request_uri;
    }

    #Page d'index
    index index.php index.html;

    #Conf wordpress
    location /wordpress/ {
        #On passe les .php à php-fpm
        location ~ \.php$ {
            include fastcgi.conf;
            fastcgi_intercept_errors on;
            fastcgi_pass php_www;
        }

        #Gestion cache des fichiers statiques
        location ~* \.(js|css|gif|ico|ttf|woff|woff2)$ {
            expires max;
            log_not_found off;
        }

        #Gestion cache + version webp des JPG et PNG - cf
        #https://www.colino.net/wordpress/archives/2022/02/24/accelerer-wordpress-le-serveur-http/
        #Supprimez le bloc si vous ne faites pas de webp adaptatif
        location ~* \.(png|jpg|jpeg)$ {
            expires max;
            log_not_found off;
            add_header "Vary" "Accept";
            try_files $uri$webp_suffix $uri =404;
        }

        try_files $uri $uri/ /wordpress/index.php?$args;
    }

    #For Fediverse (https://wordpress.org/plugins/activitypub/)
    location ^~ /.well-known/webfinger {
        try_files $uri $uri/ /wordpress/?$args;
    }

    gzip_disable "msie6";

    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;
}

À ce moment là, nous n’avons plus qu’à installer Varnish et reloader Nginx:

sudo apt install varnish
sudo nginx -t && sudo nginx -s reload

Si tout se passe bien, tout fonctionne comme avant… Et Varnish ne cache rien, … car son fichier de configuration VCL ne fait rien.

Éditons ce fichier /etc/varnish/default.vcl pour lui expliquer ce qu’il doit faire et dans quelles conditions mettre une page en cache, servir une page en cache, etc. C’est un peu long, j’espère que les commentaires suffisent à expliquer le processus.

#déclaration du format
vcl 4.0;
import std;

#sous-fonction préparant le terrain pour mettre en cache les images
#sous deux formes différentes, jpg/png et webp,en fonction du navigateur
#supprimez la fonction si pas de webp adaptatif
sub webpdetect {
  unset req.http.image;
  unset req.http.webp;

  #Si la requête est pour une image .jpeg, .jpg, .png (case-insensitive)
  #Si oui, on sette le header req.http.image à "true"
  if (req.url ~ "\.(jpeg|jpg|png)$") {
    set req.http.image = "true";

    #Le navigateur prend-il webp en charge ? Si oui, on sette
    #le header req.http.webp à "true"
    if (req.http.Accept ~ "image\/webp") {
      set req.http.webp = "true";
    }
  }
}

#Définition du backend, nginx sur le port 8080
backend default {
    .host = "127.0.0.1";
    .port = "8080";
}

#Fonction appelée avant de vérifier si la page est dispo en cache.
#C'est ici qu'on "nettoie" la requête de tous ses attributs inutiles,
#afin de maximiser l'utilisation du cache.
sub vcl_recv {
    #On appelle notre helper pour déterminer si webp est possible/supporté
    call webpdetect;

    #On indique que le backend sera notre backend "default", nginx
    set req.backend_hint = default;

    #S'il y a une entête d'authentification http,
    #on ne cache pas
    if (req.http.Authorization 
     || req.http.Proxy-Authorization) {
        return (pass);
    }

    #Si la requête n'est pas un simple GET/HEAD,
    #on ne cache pas.
    if (req.method != "GET"
     && req.method != "HEAD") {
        return (pass);
    }
 
    #On ne met rien dans l'admin en cache
    if (req.url ~ "/wp-(login|admin)") {
        return (pass);
    }

    #On ne cache pas les pages panier/commande de Woocommerce
    if (req.url ~ "/(cart|my-account|checkout|addons)") {
        return (pass);
    }
    if ( req.url ~ "\?add-to-cart=" ) {
        return (pass);
    }
 
    #Les cookies changent parfois la vue d'une page, ils sont donc
    #utilisés par Varnish pour différencier le cache d'une page selon
    #les cookies - une page peut être cachée dans de multiples versions
    #par Varnish, cf plus bas. Nous supprimons donc tous les cookies qu'on
    #sait inutile. Plus fiable serait d'auditer le code de WordPress pour
    #ne garder que ceux qu'on sait utiles, mais c'est plus casse-gueule.
    #Mieux vaut un moins bon hitrate que de servir le mauvais cache à la
    #mauvaise personne.

    # "has_js" cookie
    set req.http.Cookie = regsuball(req.http.Cookie, "has_js=[^;]+(; )?", "");
 
    # Google Analytics cookies
    set req.http.Cookie = regsuball(req.http.Cookie, "__utm.=[^;]+(; )?", "");
 
    # wp-settings-* cookie
    set req.http.Cookie = regsuball(req.http.Cookie, "wp-settings-.=[^;]+(; )?", "");

    # wp-settings-time-* cookie
    set req.http.Cookie = regsuball(req.http.Cookie, "wp-settings-.=[^;]+(; )?", "");

    # wordpress_test cookie
    set req.http.Cookie = regsuball(req.http.Cookie, "wordpress_test_cookie=[^;]+(; )?", "");
 

    # Remove the _ga cookies
    set req.http.Cookie = regsuball(req.http.Cookie, "_ga=[^;]+(; )?", "");
    set req.http.Cookie = regsuball(req.http.Cookie, "_gat=[^;]+(; )?", "");
    set req.http.Cookie = regsuball(req.http.Cookie, "_gat_gtag_UA_[0-9]+_[0-9]+=[^;]+(; )?", "");
    set req.http.Cookie = regsuball(req.http.Cookie, "_gid=[^;]+(; )?", "");

    #Reste-t'il des cookies ?
    #Si non, on supprime complètement l'entête.
    if (req.http.cookie ~ "^ *$") {
            unset req.http.cookie;
    }
 
    #Quels que soient les cookies restants, 
    #on les enlève sur les fichiers statiques.
    if (req.url ~ "\.(css|js|png|gif|jp(e)?g|swf|ico)") {
        unset req.http.cookie;
    }

    #On normalise l'entête Accept-Encoding
    # https://www.varnish-cache.org/docs/3.0/tutorial/vary.html
    if (req.http.Accept-Encoding) {
        # Do no compress compressed files...
        if (req.url ~ "\.(jpg|png|gif|gz|tgz|bz2|tbz|mp3|ogg)$") {
            unset req.http.Accept-Encoding;
        } elsif (req.http.Accept-Encoding ~ "gzip") {
            set req.http.Accept-Encoding = "gzip";
        } elsif (req.http.Accept-Encoding ~ "deflate") {
            set req.http.Accept-Encoding = "deflate";
        } else {
            unset req.http.Accept-Encoding;
        }
    }

    #Pour ActivityPub
    set req.http.activitypub = "false";
    if (req.http.Accept) {
        if (req.http.Accept ~ "application/activity\+json") {
            set req.http.activitypub = "true";
        }
    }

    #S'il nous reste des cookies, on ne cache pas !
    if (req.http.Cookie) {
        # Not cacheable by default
        return (pass);
    }

    #Maintenant, on peut envisager de cacher.
    #Pour cela, on va calculer un hash de la requête de manière à ce que
    #chaque requête identique aie le même hash et se fasse servir le même
    #cache, et que chaque requête différente aie un hash différent.
    return(hash);
}

#Hash de la requête
sub vcl_hash {
    #Bien sûr avant tout, chaque URL doit avoir un cache différent.
    hash_data(req.url);

    #En fonction du site pour lequel est la requête, on différencie.
    if (req.http.host) {
        hash_data(req.http.host);
    } else {
        hash_data(server.ip);
    }

    #On différencie suivant le protocole, http ou https
    #(http aura des 301 en cache, https des pages)
    if (req.http.X-Forwarded-Proto) {
        hash_data(req.http.X-Forwarded-Proto);
    }

    #Si c'est une image, on différencie en fonction de la capacité
    #du navigateur à lire le format webp.
    #supprimez le bloc si pas de webp adaptatif
    if (req.http.image) {
        hash_data(req.http.webp);
    }

    hash_data(req.http.activitypub);

    #Et on va chercher dans le cache.
    return (lookup);
}

#Dans cette fonction, on nettoie la réponse du serveur upstream, nginx,
#et on y ajoute des informations.
sub vcl_backend_response {
    #On définit la durée par défaut pendant laquelle on cachera une page
    set beresp.ttl = 24h;
    set beresp.grace = 1h;
  
    #On indique qu'on n'a pas caché suivant la méthode
    if ( bereq.method == "POST" 
      || bereq.http.Authorization
      || bereq.http.Proxy-Authorization ) {
        set beresp.http.X-Debug = "M/A";
        set beresp.uncacheable =true;
        return (deliver);
    }
 
    #Et enfin, on délivre le résultat.
    set beresp.http.X-Debug = "deliver";
    return (deliver);
}

sub vcl_deliver {
        #On ajoute un entête pour vérifier le fonctionnement du cache.
        if (obj.hits > 0) {
                set resp.http.X-Cache = "HIT";
        } else {
                set resp.http.X-Cache = "MISS";
        }
}

Avec cela vous devriez commencer à voir des X-Cache: HIT en allant, non connecté ou bien dans une session de navigation privée, visiter votre site. Vous verrez aussi des temps de réponse divisés par un facteur 10.

Il nous reste une chose importante à faire : permettre de facilement discarder les caches d’une page lorsqu’elle change (par exemple, le contenu du post est mis à jour ; ou bien, un·e internaute poste un commentaire, etc).

Pour cela on va installer un petit plugin WordPress, Purge Varnish. On le configure pour le brancher à notre varnish :

La Varnish Control Key provient du fichier /etc/varnish/secret.

Ensuite on indique au plugin dans quelles circonstances on veut « expirer » un cache. Généralement, à chaque fois qu’on touche un post, on veut expirer la page du post, celles de ses catégories, et la home page. Lorsqu’on touche un commentaire, on veut expirer la page du post, potentiellement la home page si les derniers commentaires y sont visibles. Etc.

Si vous vous souvenez de la génération automatique de fichiers webp à partir des jpg et png, vous savez que lorsqu’on uploade une image, elle s’affiche ensuite dans l’éditeur. Pour cela le navigateur envoie un GET sur <image.jpg>. Varnish ne l’a pas en cache, c’est la première fois qu’on lui demande. Donc il demande à nginx, qui regarde si elle existe en webp. Elle n’existe pas encore en webp car le script n’a pas eu le temps de tourner. Il la renvoie donc en jpg. Varnish la prend, et la met en cache, au format jpg, même si votre navigateur gère le webp.

Le problème, c’est qu’une minute plus tard, l’image est convertie en webp par notre script, mais maintenant, Varnish l’a en cache avec son format d’origine, y compris pour les navigateurs gérant le webp. Nous allons donc modifier le script de conversion d’image pour, après une conversion, expirer le cache d’une image donnée :

for i in $FILES; do
        if [ -f "$i" -a ! -f "$i.webp" ]; then
                convert -strip "$i" "$i.webp"
                if [ "$?" = "0" ]; then
                        URI=/wordpress/wp-content/uploads/$(basename "$i")
                        varnishadm ban "req.http.host == www.colino.net \ 
                          && req.url ~ $URI"
                fi
        fi
done

Et voilà, on est à peu près prêt.

On voit très vite quand Varnish sert du cache :