Varnish et Drupal 9 : le vidage de cache ciblé

La mise en place d'un cache de pages anonymes Varnish devant un Drupal 9 permet une mise en place relativement aisée d'un vidage automatique des pages mises en cache en se basant sur la politique de tags de Drupal. Cet article devrait vous donner les bases pour commencer à comprendre et expérimenter le système.

Le blog Makina-corpus

La mise en place d'un cache de pages anonymes Varnish devant un Drupal 9 permet une mise en place relativement aisée d'un vidage automatique des pages mises en cache en se basant sur la politique de tags de Drupal. Cet article devrait vous donner les bases pour commencer à comprendre et expérimenter le système.

Varnish en cache de pages

Il y a quelques temps de cela nous avions publié un article assez long sur la mise en place de Varnish en cache de pages anonymes devant un Drupal.

Celui-ci était basé sur un Drupal 7, et pourrait être appliqué sur un Drupal 8 ou 9 sans trop de modifications.

Cependant les versions récentes de Drupal amènent la gestion des tags (étiquettes) par page, et permettent d'imaginer beaucoup plus simplement que par le passé une politique de vidage progressif des pages dans le cache Varnish.

L'article cité précédemment ne s'attardait pas sur d'éventuelles techniques de vidage ciblé du cache, un simple reload de varnish permet de vider complètement le cache en dernier recours.

Si nous regardons d'assez haut le système de gestion de tags par page nous pouvons obtenir un premier schéma intéressant :

Image
Tags de cache & purge du cache Varnish

 

Par rapport à la situation en Drupal 7 il y a une seule différence. Des tags sont associés à chaque page générée, et concaténés dans un Header HTTP de la réponse. On peut donc utiliser ce header de réponse dans Varnish et associer ces informations à chaque page stockée en mémoire dans Varnish. Ces informations n'étant d'aucune utilité pour le client final on veillera aussi à supprimer ces headers très verbeux dans la page finale servie par Varnish vers le client final. Nous détaillerons plus loin dans l'article les changements à apporter dans le VCL de varnish pour obtenir ces deux éléments (stockage des tags pour chaque objet et suppression des headers en sortie).

Voilà, pour le moment rien de bien nouveau, nous avons un cache de pages anonymes servi par Varnish, et pas encore de système de purge en place. Il nous faut donc regarder à quoi ressemblera ce mécanisme de purge. Il sera assez rapide à détecter les mises à jour de contenus impactant des pages du cache, mais ne sera pas synchrone (pas automatique), et qui dit asynchronisme dit crons. Ce qui nous amène à la partie suivante.

Utilisez des crons Drupal

Côté Drupal nous allons avoir besoin de tâches régulières et asynchrones. Dans notre cas le but de ces tâches asynchrones sera d'aller vider des parties du cache Varnish régulièrement.

Pour avoir des tâches asynchrones en Drupal il y a plusieurs méthodes. Par exemple on peut se reposer sur un système de poor-man's-cron qui lance les tâches asynchrones en fin de traitement d'une réponse classique à un utilisateur (après avoir envoyé la réponse au serveur Web). Mais ce n'est pas le meilleur outil.

Sur un Drupal un peu avancé, il est conseillé d'avoir un cron système, lançant régulièrement des tâches asynchrones pour Drupal.

On rencontre là aussi plusieurs méthodes, la pire consistant à effectuer une requête web vers le Drupal avec curl ou wget, sur une url spécifique de drupal (/cron.php avec une clef de sécurité). Je dis "la pire" parce qu'on se retrouve à repasser par le serveur web, puis par mod_php ou php-fpm, et Drupal tourne en mode web. En mode web il y a des limites sur le temps de travail de PHP et sa consommation de mémoire.

La deuxième méthode, la meilleure, est d'utiliser drush, donc un PHP qui tourne en ligne de commande (php-cli), avec des limites de temps et de mémoire propres au php-cli, indépendantes des limites du mode web (assurez vous de ne pas utiliser le même php.ini en mode cli).

Vous pourriez donc lancer la commande drush visant l'exécution des crons de Drupal assez régulièrement. Mais cette commande effectue des tâches un peu lourdes, et par exemple le cron général de Drupal devrait tourner 2 fois par jour, mais pas toutes les 5 minutes.

Hors certaines tâches comme le vidage des pages Varnish seraient utiles avec un intervalle de temps assez court (quelques minutes au maximum).

Et bien là aussi il y a plusieurs solutions.

  • Première solution vous enregistrez plusieurs lancements de crons drush différents au niveau du cron système (de l'OS), avec des rythmes différents, quitte à faire des commandes drush spécifiques pour vos traitements en particuliers
  • Deuxième solution vous lancez la commande drush de cron toutes les minutes et vous ajoutez un module Drupal de type ultimate_cron qui va proposer sa propre table interne d'ordonnancement (cf schéma ci-dessous. Vous aurez dès lors dans l'interface d'administration de Drupal une liste des tâches, avec pour certaines des intervalles très courts (vider la queue de purge des caches Varnish toutes les minutes par exemple) et d'autres plus espacées (faire tourner tel ou tel import de fichier chaque nuit vers 2h00).
Tâches Asynchrones

 

J'imagine que vous mettrez en place un système de ce type. Mais il n'a rien d'obligatoire, il faut cependant garder à l'esprit que la purge sera basée sur un système asynchrone. Si je prends en exemple un hébergement Drupal de type Acquia Cloud/ACSF vous aurez très certainement un cron de type drush à ajouter pour déclencher à rythme plus rapide le module acquia_purge — qui est un module très proche du varnish_purge, les deux s'influencent —, et vous auriez par défaut un système 'late runtime' en secours pour vider la queue de purge en fin de requête web de temps en temps, mais vous n'aurez sans doute pas de module de type ultimate_cron. Donc des solutions de traitements asynchrones, mais variées.

Revenons à nos moutons : la purge de varnish

Voici le schéma de fonctionnement de la purge:

  • des tags sont identifiés comme nécessitant des purges, par Drupal, lors de mises à jour de pages ou de structures dans le site (comme des blocks)
  • ces tags sont collectés par des crons de purge
  • des requêtes HTTP de type BAN (et donc pas des POST ou des GET) sont émises à destination de(s) Varnish(s), en provenance de la machine qui fait tourner les crons (attention avec docker si vous avez un container dédié aux crons, c'est lui qui émet ces requêtes et qui doit y être autorisé par Varnish)
Image
Tags de Cache & Purge du cache Varnish
  • une fois que les BAN arrivent dans Varnish les tags à supprimer sont stockés dans un 'store' dédié
  • un processus asynchrone s'assure de vider la mémoire des pages ayant un des tags référencés dans ce store (puis le store est vidé du tag en question)
  • quand une demande arrive pour une page du cache on vérifie qu'elle ne comporte pas un tag stocké dans le store (ou que la page existe dans la cache, si elle a été supprimée par le ban lurker elle sera absente)
  • au final si la page est absente ou présente on revient au premier schéma, soit elle est servie depuis le cache, soit on demande la nouvelle version à Drupal avant de la servir.

Il y a des petites subtilités possibles:

  • vous pouvez avoir plusieurs serveurs Varnish, à l'heure actuelle il vous faudra un patch pour le module varnish_purge (en version "^2.0"). Pour le moment avec un D9 nous utilisons d'ailleurs 2 patchs:
    // composer.json, section patchs
    (...)
    "drupal/varnish_purge": {
       "Multiple IP for one varnish purger": "https://www.drupal.org/files/issues/2020-03-31/varnish_purge-multiple_ips-2843978-14.patch",
       "config_export key is missing from the config entity": "https://www.drupal.org/files/issues/2020-06-05/3146499-2.patch"
     },

Donc surveillez les issues #2843978 et #3146499.

  • le système de BAN n'est pas le seul supporté par Varnish. Si vous consultez cette page de référence dans la doc Varnish : vous y verrez au moins deux autres méthodes qui, à la différence du BAN, ne sont pas asynchrones, les requêtes PURGE et les système de 'Surrogate keys'. Si jamais le système du BAN ne vous convient pas vous pourrez tester d'autres modes.
  • utilisez des Varnish récents, de la même façon qu'on installe rarement un Drupal 6 en 2021, on ne devrait pas non plus utiliser un ancien Varnish en production. Je dis cela parce que malheureusement on croise encore des Varnish 3.x ou 4.x en production et qu'actuellement on est plutôt sur du 6.x (et aussi parce que dans une autre vie je testais les impacts des requêtes HTTP malformées sur divers serveurs HTTP et autres reverse proxy caches, et de cette expérience j'ai retenu qu'il vaut mieux garder des serveurs HTTP à jour).

Pour la suite nous allons juste détailler les implications de cette architecture dans le VCL de Varnish et dans les modules et configurations de Drupal.

Modifications de VCL côté Varnish

On a vu la théorie, commençons un peu la technique. Côté Varnish pour commencer. J'imagine que vous avez déjà un VCL fonctionnel pour Varnish, référez vous à d'autres articles pour cela.

Il va vous falloir une section d'acl pour définir un groupe de serveurs (par nom ou IP) autorisés à envoyer les requêtes BAN (le but n'étant pas de permettre à tous vos visiteurs d'envoyer des requêtes de vidage de cache).

# servers allowed to send BAN requests
acl ban_agents {
    "localhost";
    "app1";
    "app2";
    "app3";
    "cron1";
    "cron2";
}

Et donc ensuite nous allons ajouter une section qui vérifie l'origine des requêtes BAN dans vcl_recv, qui gère la réception des requêtes par Varnish. En rejetant les autres avec une réponse 405 (méthode BAN non autorisée).

sub vcl_recv {
    # Happens before we check if we have this in cache already.
   (...)
    # Put that almost on top of the vcl_recv
    # --------------------------------------
    #
    #   X-Cache-Tags: foo
    # It will ban (sort of purge on match) any page that was recorder with the
    # right X-Cache-Tags in the response, like this one for example
    #    HTTP/1.1 200 OK
    #    Content-Type: text-html
    #    Content-Length: 5244
    #    X-Cache-Tags: foo,bar,toto,user_441
    #    ...
    if (req.method == "BAN") {
        if (!client.ip ~ ban_agents) {
            return(synth(405,"Not allowed."));
        }
        # ensure no upcase/downcase mistake
        set req.http.Cache-Tags = std.tolower(req.http.Cache-Tags);
        # ban are constructed on the obj.* and not req.* to become "ban lurker" friendly
        # so that they are cleaned up in a background task.
        # always do that unless you implement key surrogate direct purges
        # @see vcl_backend_response for X-Ban-Path & X-Ban-Purge-Tags recording on this
        # cached obj.
        if ((req.url == "/tags") && req.http.Cache-Tags) {
            # drupal 9 cache-tags Ban
            set req.http.Cache-Tags = "(^|\s)" + regsuball(std.tolower(req.http.Cache-Tags), "\ ", "(\\s|$)|(^|\\s)") + "(\s|$)";
            ban("obj.http.X-Ban-Purge-Tags ~ " + req.http.Cache-Tags);
            return (synth(200, "Tags Ban added."));
        }
        else {
            # ensure no upcase/downcase mistake
            set req.url = std.tolower(req.url);
            if (req.url ~ "\*") {
                # Uri wildcard Ban
                set req.url = regsuball(req.url, "\*", "\.*");
                ban("obj.http.X-Ban-Path ~ ^" + req.url + "$");
                return (synth(200, "Wildcard Url Ban added."));
            }
            else {
                # Uri Ban
                ban("obj.http.X-Ban-Path == " + req.url);
                return (synth(200, "Url Ban added."));
            }
        }
    }

Les parties suivant le rejet en 405 sont plus complexes, on y définit trois modes de ban possibles, (en fonction de l'url utilisée dans la requête BAN).

  • BAN /tags : on veut bannir les pages qui contiennent un des tags passés en argument dans le header Cache-Tags
  • BAN /foo* : on veut bannir un ensemble d'url qui match le wildcard
  • BAN /ma-page : le plus simple on veut bannir une url précise

En fonction des cas, on applique quelques transformations sur les arguments (soit l'url, soit le contenu du header Cache-Tags), pour obtenir une syntaxe compréhensible par le ban lurker, et on enregistre le fait qu'un ban est demandé (fonction ban()). Les traitements du ban se feront plus tard, en asynchrone, pour le moment on renvoie une réponse 200 à la requête BAN reçue (comme cela Drupal pourra acter que sa requête de purge est prise en compte).

Vous avez certainement vu qu'il est fait référence à vcl_backend_reponse pour le traitement de ces headers X-Ban-Path/X-Ban-Purge. Voici donc la partie à ajouter.
On y récupère l'url et le header Cache-Tags de la réponse en provenance du backend (donc de Drupal) et on stocke ces informations dans 'beresp' qui est l'objet que varnish va stocker en mémoire dans son cache (on stocke cela dans les headers X-Ban-Path pour l'url et X-Ban-Purge-Tags pour la liste des tags).

Ce vcl_backend_response n'arrive pas du tout au moment du traitement des requêtes BAN, ici il s'agit d'un traitement appliqué en temps normal, sur toutes les réponses reçues en provenance de Drupal (donc dans le cadre de notre premier schéma), c'est ce qui va permettre d'enregistrer les tags dans le cache, pour pouvoir les retrouver plus tard.

sub vcl_backend_response {

    (...)
    # Put this in the middle of vcl_backend_response, after the
    # = 500 and the bereq.http.do-not-use-50x-responses section
    # of previous vcl examples, for example.
    # ---------------------------------------
    #
    # Cache Cleanup
    #
    # record stuff for the ban-lurker cleanup process
    # (sort of garbage collector working with bans)
    set beresp.http.X-Ban-Path = std.tolower(bereq.url);
    set beresp.http.X-Ban-Purge-Tags = std.tolower(beresp.http.Cache-Tags);

    (...)

    set beresp.http.X-Varnish-TTL = beresp.ttl;

    return(deliver);
}

Il est important de stocker ces informations dans les pages du cache, puisque le BAN lurker fera une recherche dans ce cache des pages à détruire. Rappelez-vous que nous avons définit les opérations de BAN dans vcl_recv sous cette forme:

ban("obj.http.X-Ban-Purge-Tags ~ " + req.http.Cache-Tags);
ban("obj.http.X-Ban-Path ~ ^" + req.url + "$");
ban("obj.http.X-Ban-Path == " + req.url);

Ce qui signifie que le BAN lurker va rechercher des obj (les objets sont les pages du cache, les beresp reçues sont devenues des obj), contenant les headers HTTP X-Ban-Purge ou X-Ban-Path matchant les expressions régulières passées en paramètre.

Il nous reste à nous assurer que nos réponses finales (les réponses HTTP renvoyées par Varnish) ne contiendront pas les headers techniques qui stockaient toutes les informations liées à la gestion de ce cache par tag (comme les X-Ban-Path et X-Ban-Purge-Tag que nous avons ajouté aux réponses stockées dans le cache). En test vous pouvez garder une partie de ces headers, mais en production il est préférable de les enlever. Ceci se passe dans vcl_deliver qui est la section relative à l'envoi d'une réponse vers le navigateur client.

sub vcl_deliver {
    (...)
    # put that on the bottom of vcl_deliver
    # -------------------------------------------------
    #
    # Remove headers on final client response
    # hide Cache Tagging
    unset resp.http.X-Cache-Tags;
    # this is from varnish_purger
    unset resp.http.Cache-Tags;
    unset resp.http.X-Drupal-Cache-Contexts;
    unset resp.http.X-Drupal-Cache-Tags;
    unset resp.http.X-Drupal-Dynamic-Cache;
    unset resp.http.X-Varnish-TTL;
    unset resp.http.X-Varnish;
    unset resp.http.X-Ban-Path;
    unset resp.http.X-Ban-Purge-Tags;
    # maybe you'll need these ones in test/staging environements
    unset resp.http.X-Varnish-Cache-Hits;
    unset resp.http.X-Varnish-Cacheable;
}

Voilà pour Varnish. Il reste à configurer Drupal pour la gestion de ces tags et l'émission des requêtes BAN.

Modules et réglages Drupal

Du côté de Drupal, il va vous falloir un cache des pages anonymes, pour que Drupal génère des pages anonymes avec une durée de cache supérieure à zéro et les bons headers de cache. Puis la gestion des tags de cache dans les headers des pages émises par Drupal. et enfin tout le système de purge, avec ses plugins pour une purge varnish.

Pour mes expérimentations j'ai ajouté 3 modules avec composer:

composer require drupal/big_pipe_sessionless:^2.0
composer require drupal/purge:^3.0
composer require drupal/varnish_purge: ^2.0

Avec des patchs pour le module varnish_purge, tel qu'indiqué plus haut.

Mais ces modules arrivent avec des dépendances, au final j'ai du activer tous ces modules (ici un extrait du fichier core.extension.yml):

// drupal/config/sync/core.extension.yml
  purge: 0
  purge_processor_cron: 0
  purge_queuer_coretags: 0
  purge_tokens: 0
  purge_ui: 0
  varnish_image_purge: 0
  varnish_purge_tags: 0
  varnish_purger: 0

ERRATUM: dans la première version publiée de cet article page_cache était aussi ajouté. Une habitude tirée de Drupal7. Avec Drupal 9 il n'est pas nécessaire d'ajouter ce module, Drupal est en mesure d'émettre les bon headers de cache-Control à destination de Varnish sans que le cache de page de Drupal ne soit utilisé (merci à Icube45 pour le fix).

Vous aurez alors à définir quelques objets de configuration. Côté GUI, tout se cache dans admin/config/development/performance avec toujours le champ important de réglage de temps de cache "Âge maximum du cache des navigateurs et proxy" et l'ajout de nouveaux onglets, dont celui nommé "Purge".

Cet écran contient pas mal d'informations, et différents choix possibles. Je vous montre ici un exemple de réglage fonctionnel pour un vidage du cache par tag (sachez qu'il y a par exemple des configurations cachées pour lister les tags à ne pas traiter dans la purge, si vous savez qu'une modification sur tel ou tel tag de Drupal ne nécessite pas de vider les pages y faisant référence)

Image
Purge

 

Par rapport à l'écran que vous aurez par défaut vous voyez que j'ai déjà créé un 'purger' que j'ai nommé 'varnish purger xxxxx'. Ce purger va en effet hérité d'un nom contenant des caractères pris au hasard.

Si je l'édite on peut y voir plus de réglages intéressants :

Configurer Varnish Purger

 

Par nature vos réglages seront différents, c'est cependant ici que j'ai défini que je voulais appliquer une purge par tags (type étiquette), il y en a d'autres disponibles, je vous laisse expérimenter car comme nous avons pu le voir du côté de Varnish on peut traiter plusieurs types de purges (par url fixe, avec wildcards, etc.).

Pour le nom d'hôte il vous faut le patch sus-mentionné pour pouvoir en donner plusieurs, il s'agit du nom d'hôte ou de l'IP du ou des serveurs Varnish. Le port est le port sur lequel votre varnish acceptera des requêtes en provenance de Drupal.

Sur le deuxième onglet il faut indiquer la bonne expression (l'onglet tokens essaye de vous donner des exemples, mais je vous conseille la lecture du code source pour trouver les bons tokens).

Paramétrer Varnish

 

Les autres onglets sont assez simples à comprendre.

Au final, vous aurez certainement besoin d'exporter ces éléments de configuration pour les partager avec d'autres instances de votre site (preprod, prod, etc.). Vous devriez avoir à exporter:

  • purge.logger_channels.yml : les réglages de logs de tous les éléments de cette page (caché dans le menu en bas à droite sur l'écran purge)
  • purge.plugins.yml
  • purge_queuer_coretags.settings.yml
  • varnish_purger.settings.0569b3d8aa.yml : ici on repère bien sur une partie variable dans le nom du purger, qui sera différente pour vous.
  • ultimate_cron.job.purge_processor_cron_cron.yml : si vous utilisez ultimate cron, n'oubliez pas de rafraîchir la liste des crons disponibles. La tâche de cron par défaut, extraite du module purge_processor_cron n'a pas un nom très clair dans l'interface (quelque chose comme 'cron' ce qui n'est pas très utile. J'ai par exemple modifié sont attribut title: title: 'Purge cron (varnish cache purge)'.

Mais ce n'est pas fini, si vous avez plusieurs instances du site il est très probable que l'adresse des serveurs varnish doit varier en fonction de l'environnement, et donc dans les settings de Drupal..

Attention au niveau des settings ce n'est plus une chaîne avec un séparateur espace, c'est bien un array qu'il faut donner:

# fichier de setting.php de Drupal
# Varnish purge settings, target the real Varnish servers -------------
$config['varnish_purger.settings.0569b3d8aa']['hostname'] = [
  10.0.0.2,
  10.0.0.3,
  10.0.0.4
];
$config['varnish_purger.settings.0569b3d8aa']['port'] = '80';

Et vous remarquerez là encore le nom pas du tout prédictible du Purger, parce que c'est tellement mieux quand c'est compliqué avec Drupal 9 :-)

Réglages PHP/Nginx/Apache

Nous avons fait le tour et normalement vous avez tous les éléments pour bien démarrer. Attention cependant, Drupal peut générer un très grand nombre de tags pour une seule et même page.

Tout cela dépend de votre politique de gestion des étiquettes, de vos layouts, du nombre de blocs et de références à différents nœuds dans vos pages.

Ces étiquettes terminent dans un header HTTP, que Varnish va stocker avec votre page.  Et il y a plusieurs limites qui peuvent entrer en jeu et faire disparaître vos réponses (donc plus de site...).

* un header HTTP ne devrait jamais dépasser 8000 caractères, ce n'est pas dans la RFC mais c'est une limite que de nombreux serveurs HTTP appliquent

* Ces buffers vont transiter entre le serveur HTTP de votre Drupal (Apache ou Nginx souvent) et le serveur varnish. Mais si vous avez php-fpm ils vont aussi transiter entre le serveur HTTP (Apache ou Nginx, donc) et php-fpm, via le protocole fast-cgi. Et dans ce protocole aussi des gros headers peuvent poser problème. Par exemple j'ai du augmenter sur Nginx la taille des ces buffers:

    fastcgi_buffer_size 128k;
    fastcgi_buffers 4 256k;
    fastcgi_busy_buffers_size 256k;

Bref, soyez méfiants avec ces tags de cache. Par contre quand le système fonctionne vous obtenez un cache anonyme rafraîchi quelques minutes après les modifications, et le système est très agréable.

Inscription à la newsletter

Nous vous avons convaincus