Utilisez Varnish et le cache ESI pour diviser le temps de réponse de votre site Web par 100

Le cache ESI est tellement sous-côté. Paré.e pour le décollage ?

Utilisez Varnish et le cache ESI pour diviser le temps de réponse de votre site Web par 100

Le but de cet article est de vous faire comprendre les différents mécanismes qui peuvent être utilisés avec Varnish et le cache HTTP (TTL, Cache-Control, fragments ESI).
On va étudier un cas pratique avec une app PHP mais ces principes sont valables quel que soit le langage derrière, ce qui compte vraiment sont les entêtes de réponses que vous fournissez !

On évoque souvent la mise en place du cache applicatif type Redis comme la première solution pour optimiser les temps de réponse des sites. Découvrez ici seulement la vraie solution pour réduire les temps de réponse de votre site Web que seul 1% des gens connait.

Si vous lisez encore ces lignes, c'est que j'ai bien fais mon travail de teasing. Sans plus attendre, voici la solution ultime pour réduire les temps de réponse de votre site : Ne pas appeler votre site.

Mais quelle drôle d'idée.
Mais quelle drôle d'idée.

Mine de rien, démarrer un process Web et retourner une réponse au client, même avec un super framework récent, ça reste long et coûteux. Bootstrapping du framework, connexion à une base de données, à un serveur Redis, vérification des droits utilisateurs, moteur de templating, etc etc. Sans trop forcer, vous pouvez vite atteindre les 200 ms pour retourner du contenu.

🤓 « Alors comment faire pour réduire mes temps de réponse ? Est-ce que je dois investir, booster mon serveur de prod et doubler le montant ma facture ? »

Pas du tout ! L'idée est d'utiliser à fond le cache HTTP pour stocker le rendu HTML généré par le backend. Pour ça, on va jouer avec Varnish, un reverse proxy-ultra puissant écrit en C. On parle ici de temps de réponse de l'ordre de quelques millisecondes si le contenu de la page est déjà présent dans le cache.

Le principe de Varnish est plutôt simple : Le reverse-proxy est en première ligne des requêtes utilisateurs. S'il possède déjà la réponse à cette requête, il la délivre directement sans interroger votre app. Sinon, il forward la request au backend et stocke le résultat (si vos entêtes lui indiquent). C'est aussi simple que ça. On va voir comment mettre en place concrètement ce flux en place.

Le principe simple de Varnish
Le principe simple de Varnish

C'est parti

L'idée est donc de mettre Varnish en frontal des requêtes utilisateur. C'est lui qui va maintenant écouter sur le port 80 et envoyer les requêtes vers votre serveur Web qui était en frontal jusqu'ici.

La première étape est de modifier les ports d'écoute de votre backend. Par exemple pour Apache on va modifier la configuration :

- Listen 80
+ Listen 8000

Gardez en tête le nouveau port d'écoute de votre serveur Web. Ensuite, on va installer Varnish. Rendez vous sur packagecloud et suivez les instructions selon votre distribution : https://packagecloud.io/varnishcache.

Un premier exemple : du contenu statique

Varnish utilise son propre langage de configuration (VCL) qu'il compile ensuite en C. C'est un poil bas niveau mais une fois bien configuré, vous allez mettre un moteur de Ferrari dans votre 2 CV.
On va commencer par un VCL minimaliste :

# /etc/varnish/default.vcl
vcl 4.1;

backend default {
    .host = "127.0.0.1"; 
    .port = "8000";  # Le port d'écoute de votre backend (Apache, Nginx, ...)
}

# Appelé à la fin de chaque réponse (en cache ou non)
sub vcl_deliver {
    if (obj.hits > 0) {
        set resp.http.X-Cache = "HIT";
    } else {
        set resp.http.X-Cache = "MISS";
    }

    set resp.http.X-Cache-Hits = obj.hits;
    
    return (deliver);
}

Prenez le temps de vous familiariser avec la syntaxe et la configuration. Ici, on a indiqué à Varnish qu'il existe un backend (default) accessible à l'adresse 127.0.0.1:8000 (souvenez-vous, c'est le nouveau port d'écoute de votre serveur Web).
On a aussi configuré une entête de réponse que l'on a nommé X-Cache pour savoir si la réponse a été récupérée du cache ou non.
Vérifions le résultat en appelant la page d'accueil de notre site pour la première fois :

Première requête : Varnish n'a rien en cache
Première requête : Varnish n'a rien en cache

Étant donné que c'est la première fois que l'on accède à la ressource, Varnish ne possède pas la réponse dans son cache, ce qui est logique. Il appelle donc le backend normalement et stocke le résultat pour une durée de 120 secondes (c'est la configuration par défaut de Varnish, vous pouvez bien sur le changer selon vos besoins). Si on actualise la page, on constate que Varnish réagit différemment :

Deuxième requête : Varnish retourne directement la réponse
Deuxième requête : Varnish retourne directement la réponse

Parfait ! Varnish a directement retourné le résultat qu'il avait stocké lors de la première requête. Le backend n'a purement pas été sollicité, vous n'aurez aucune trace d'accès dans les logs de votre serveur Web. Ça commence à ressembler à quelque chose.

🤓 « Mais si mon contenu varie en fonction de l'utilisateur... Je ne veux pas retourner le même contenu pour tout le monde. Comment faire ? »

C'est là qu'interviennent les fragments ESI, qui sont selon moi une source de performance extrême tellement sous-côtée !

Mettre en cache des fragments de page avec le cache ESI

Il est possible de mettre en cache une partie de la page pendant une certaine durée grâce aux blocs de cache ESI (Edge Side Includes). Cette specification a été écrite par Akamai il y a une dizaine d'années dans le but de mettre en place des stratégies de cache différentes par "bloc". Par exemple : ma page possède un menu type navbar avec le nom de l'utilisateur connecté. Le reste de ma page est identique pour tout le monde et affiche une liste de produits. Je peux donc mettre en place un bloc qui va contenir le menu et mettre en cache tout le reste.

Assez de théorie, voyons comment ça se traduit dans une app :

<!DOCTYPE html>
<html>
    <body>
        <h1>Ma super app</h1>
        
        <!-- Le bloc du ESI est défini là -->
        <esi:include src="/mon-menu-prive.php"/>
    </body>
</html>

C'est aussi simple que ça ! On peut le voir un peu comme un appel Ajax qui serait fait pour chaque bloc, sauf que c'est le reverse-proxy (Varnish) qui fait la requête, vous ne verrez pas de balise <esi> dans le code source généré.

Un cas d'étude concret

On va mettre en plage une petite app PHP qui sera composée de plusieurs blocs. Certains blocs vont varier en fonction de l'utilisateur (ex: un menu), d'autres seront publics. La durée de mise en cache des blocs va aussi être variable.

Démo de labs.silarhi.fr/esi
La magnifique démo de cet article : 👉 https://labs.silarhi.fr/esi/

Entrons dans le vif du sujet, voici le code :

<?php
    // index.php
    header('Surrogate-Control: abc=ESI/1.0');
    header("X-Reverse-Proxy-TTL: 10");
?>
<!DOCTYPE html>
<html>
<head>
	<meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous">
	<title>Ma super page</title>
</head>
<body class="public cached p-2">
	<esi:include src="/menu.php"/>
	<div class="container">
		<esi:include src="/time.php"/>
		<div class="row">
			<div class="col-md-8 col-lg-9">
                <h1>Page de contenu public</h1>
                <p class="lead">
                    Mise en cache le 
                    <span class="badge badge-secondary">
                        <?= date('d/m/Y H:i:s') ?>
                    </span> 
                    (10s)
                </p>
			</div>
			<div class="col-md-4 col-lg-3">
				<esi:include src="/user.php"/>
				<esi:include src="/sidebar.php"/>
			</div>
		</div>
	</div>
</body>
</html>

Ne vous focalisez pas encore trop sur le détail de ce code, l'idée est juste d'imaginer la mise en place de vos propres blocs dans votre application.

Outre ce design de qualité, vous avez sûrement identifié les 4 tags <esi> :

  1. Le menu du haut (menu.php)
  2. L'heure du serveur avec la légende (time.php)
  3. L'heure de la dernière connexion (user.php)
  4. Les widgets (sidebar.php).

4 blocs pour 4 politiques de mise en cache différentes. C'est le mélange de la configuration dans Varnish et l'envoi d'entêtes HTTP de réponses par le backend qui vont nous permettre de mettre en place ces différentes politiques.

Configuration dans Varnish

# /etc/varnish/default.vcl
vcl 4.1;

import std;
import directors;

backend default {
    .host = "127.0.0.1";
    .port = "8000";  # Le port d'écoute de votre serveur Apache / Nginx
}

# Appelé au début de chaque requête
sub vcl_recv {
    # Pas de mise en cache pour les méthodes type POST / DELETE
    if (req.method != "GET" && req.method != "HEAD") {
        return (pass);
    }
    
    if (req.restarts > 0) {
        set req.hash_always_miss = true;
    }

    # On supprime les cookies sur les pages publiques (cf vcl_hash)
    if(! req.url ~ "^/(login|logout|menu|user)\.php") {
        unset req.http.Cookie;
    }

    # On supprime tous les autres cookies que PHPSESSID pour les pages privées
    if (req.http.Cookie) {
        set req.http.Cookie = regsuball(req.http.Cookie, "; +", ";");
        set req.http.Cookie = regsuball(req.http.Cookie, ";(PHPSESSID)=", "; \1=");
        set req.http.Cookie = regsuball(req.http.Cookie, ";[^ ][^;]*", "");
        set req.http.Cookie = regsuball(req.http.Cookie, "^[; ]+|[; ]+$", "");
        set req.http.Cookie = regsuball(req.http.Cookie, "^;\s*", "");

        if (req.http.Cookie ~ "^\s*$") {
            unset req.http.Cookie;
        }
    }

    return (hash);
}

# Appelé pour calculer un hash de la requête
sub vcl_hash {
    hash_data(req.url);

    if (req.http.host) {
        hash_data(req.http.host);
    } else {
        hash_data(server.ip);
    }

    if (req.http.Cookie) {
        hash_data(req.http.Cookie);
    }
}

# Appelé si le hash a été trouvé (= page en cache)
sub vcl_hit {
    if (obj.ttl >= 0s) {
        return (deliver);
    }
    
    return (restart);
}

# Appelé au retour de la réponse par le backend
sub vcl_backend_response {
    # L'entête est envoyée par index.php
    if (beresp.http.Surrogate-Control ~ "ESI/1.0") {
        unset beresp.http.Surrogate-Control;
        set beresp.do_esi = true;
    }

    # Notre fameuse entête custom
    if (beresp.http.X-Reverse-Proxy-TTL) {
        set beresp.ttl = std.duration(beresp.http.X-Reverse-Proxy-TTL + "s", 0s);
        unset beresp.http.X-Reverse-Proxy-TTL;
    }
    
    return (deliver);
}

# Appelé à la fin de chaque réponse (en cache ou non)
sub vcl_deliver {
    if (obj.hits > 0) {
        set resp.http.X-Cache = "HIT";
    } else {
        set resp.http.X-Cache = "MISS";
    }

    set resp.http.X-Cache-Hits = obj.hits;
    
    return (deliver);
}

Il y a plusieurs concepts clés à comprendre dans cette configuration.
L'idée de fond est de générer un hash unique pour chaque requête / sous - requête. Ce hash doit dépendre du contexte utilisateur lorsque le contenu est censé être privé, en revanche il n'est pas nécéssaire pour les blocs publics.
En PHP, l'utilisateur est lié à la session du serveur via le cookie PHPSESSID. C'est cette valeur de cookie que l'on doit conserver pour générer ce fameux hash unique dans les pages qui nous intéresse. Pour les autres pages, on peut se permettre de supprimer ce cookie dans le calcul du hash pour maximiser les chances de délivrer un contenu mis en cache par un autre utilisateur.

On supprime également les cookies non utiles au backend (du type Google Analytics etc) qui vont faire baisser le taux de HIT pour rien. Adaptez bien sûr la fonction vcl_recv en fonction de vos besoins.

L'illustration ci-dessous résume bien les différentes étapes pendant lesquelles nous devons agir :

Workflow de Varnish
Crédits : Varnish Basics
<?php
    //sidebar.php
	header("X-Reverse-Proxy-TTL: 20");
?>
<div class="public cached p-2">
	<h1>Widgets publics</h1>
	<p class="lead">
		Mise en cache le 
        <span class="badge badge-secondary">
            <?= date('d/m/Y H:i:s') ?>
        </span>
        (20s)
	</p>
</div>

Politique de mise en cache : Contenu publique mis en cache pendant 20 secondes.
C'est l'entête X-Reverse-Proxy-TTL qui va donner la durée de mise en cache par Varnish. Cette entête n'est pas standard. On aurait pu jouer avec l'entête standard Cache-Control, mais elle possède un inconvénient majeur : On ne contrôle plus la propagation du cache avec la directive Cache-Control: public, smax-age=20.
Eh oui, il peut y avoir d'autres serveurs mandataires entre le client et votre serveur qui peuvent eux-aussi mettre en cache le contenu HTML avec cette directive. Et si vous mettez des grandes valeurs de TTL dans votre politique de Cache-Control, adieu la maîtrise de vos mises à jour. Mieux vaut rester maître de la diffusion de vos contenus.
En revanche, pour vos contenus type Images/CSS/JS, faites vous plaisir et laissez le cache-control public avec un grand TTL si vous maîtrisez le cache busting.

<?php
    //menu.php
	session_start();
	header("X-Reverse-Proxy-TTL: 60");
?>
<nav class="navbar navbar-expand-lg navbar-light bg-light private cached">
	<div class="container">
		<div class="collapse navbar-collapse" id="navbarSupportedContent">
			<ul class="navbar-nav mr-auto">
				<?php if (isset($_SESSION['logged'])): ?>
					<li class="navbar-text">
						<span class="badge badge-success">Connecté</span>
					</li>
					<li class="nav-item">
					    <a class="nav-link" href="logout.php">Déconnexion</a>
					</li>
				<?php else: ?>
					<li class="navbar-text">
			    		<span class="badge badge-danger">Anonyme</span>
					</li>
					<li class="nav-item">
					    <a class="nav-link" href="login.php">Connexion</a>
					</li>
				<?php endif; ?>
			</ul>
			<span class="navbar-text">
	     		Mise en cache le 
                <span class="badge badge-secondary">
                    <?= date('d/m/Y H:i:s') ?>
                </span>
                (60s)
		    </span>
		</div>
	</div>
</nav>

Politique de mise en cache : Contenu privé mis en cache pendant 60 secondes
La mise en cache du contenu en fonction de l'utilisateur sera gérée dans Varnish directement. Le truc étant d'inclure le PHPSESSID de l'utilisateur dans le calcul du "hash" d'une requête pour ce bloc-ci, ce que nous avons fait dans la méthode vcl_recv.

time.php

<?php 
    //time.php
?>
<div class="text-center p-2 public">
	<p class="lead text-center">
        Heure serveur : 
        <span class="badge badge-secondary">
            <?= date('d/m/Y H:i:s') ?>
        </span>
        (pas de cache)
    </p>
</div>

Politique de mise en cache : Contenu publique sans mise en cache particulière.
Le backend est appelé systématiquement pour ce bloc (j'ai changé la configuration de base de Varnish pour mettre le TTL par défaut à 0s).

user.php

<?php 
    //user.php
	session_start();
?>
<br />
<div class="private p-2">
	<?php if($_SESSION['logged'] ?? false): ?>
		Dernière connexion il y a <?= time() - $_SESSION['last_login'] ?>s
	<?php else: ?>
		Pas de dernière connexion
	<?php endif; ?>
</div>

Politique de mise en cache : Contenu privé sans mise en cache particulière.
Le backend est appelé systématiquement pour ce bloc.

login.php

<?php 
    // login.php
    session_start();
    $_SESSION['logged'] = true;
    $_SESSION['last_login'] = time();

    header('Location: ./', true, 301);

Rien de particulier ici, on est sur un système de login maison très classique.

logout.php

<?php 
    // logout.php
    if ( isset( $_COOKIE[session_name()] ) ) {
        setcookie( session_name(), "deleted", time() - 3600, "/");
    }

    session_start();
    session_destroy();

    header('Location: ./', true, 301);

Il y a une particularité importante ici : On détruit entièrement le cookie de la session PHP pour que Varnish ne nous retourne pas l'ancien contenu utilisateur mis en cache qui n'a plus de raison d'exister.

Le mot de la fin

On a vu comment mettre en place plusieurs stratégies de cache différentes en fonction des types de blocs de vos pages. N'hésitez pas à vous approprier le concept de la mise en cache HTTP avec les fragments ESI et à l'utiliser massivement pour réduire drastiquement le temps de réponse de vos belles pages Web.

C'est également une architecture qui peut s'installer progressivement dans votre app. Envie d'ajouter un widget qui fait des appels externes à des pages ? Mettez en place un fragment ESI !

Ressources