On évoque souvent la mise en place du cache applicatif 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.

Démarrer un backend 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 la page existe 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 tout simplement. Sinon, il forward la request au backend et stocke le résultat. C'est aussi simple que ça. On va voir comment mettre en place concrètement ce flux en place.

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

par

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 :

vcl 4.0;

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 sur le port 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

É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

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.

Mettre en cache des fragments de page avec ESI

Il est possible de mettre en cache une partie de la page 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 propre à l'utilisateur. Le reste de ma page est identique pour tout le monde. 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. La différence est que la sous-requête est effectuée par le serveur et non par le client.

Un cas d'étude concret

On va mettre en plage une page concrète 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.

Le cas d'étude : démo disponible

Admirez ces belles bordures

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 blocs. Le menu du haut (menu.php), l'heure du serveur avec la légende (time.php), l'heure de la dernière connexion (user.php) et 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 permettre de mettre en place nos 4 politiques.

Configuration dans Varnish

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

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

    # 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 (miss);
}

# 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 pour les blocs privés, 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 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 :

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 du bloc : 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 sur l'entête standard Cache-Control, mais elle possède un inconvénient majeur : On ne contrôle pas 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 du bloc : 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 class="row">
		<div class="col-3"><span class="public p-1">Public</span></div>
		<div class="col-3"><span class="public cached p-1">Public + cache</span></div>
		<div class="col-3"><span class="private p-1">Privé</span></div>
		<div class="col-3"><span class="private cached p-1">Privé + cache</span></div>
	</div>
</div>

Politique de mise en cache du bloc : Contenu public 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 du bloc : 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.

Pour aller plus loin

La démo de l'exemple utilisé
Le code source de la démo pour bien comprendre (utilise Docker)
La documentation officielle de Varnish