Le piège du cache contextualisé et des fragments

Par Christian,
dans

sy

Nous avons récemment eu un dysfonctionnement intéressant du cache sur un de nos projets. Nous utilisons un bundle Symfony2 qui permet de facilement configurer les headers de cache sur toutes les pages de l’application, et de gérer l’invalidation de cache de façon plutôt intéressante (il s’agit de FOSHttpCacheBundle, que nous vous recommandons). Ce bundle permet notamment de gérer un cache contextualisé personnalisé.

Le fonctionnement du bundle est expliqué en détail ici. En résumé, le bundle s’interface avec votre layer de cache (Varnish ou composant HTTPCache de Symfony2) pour faire en sorte qu’à chaque requête reçue par le serveur, une sous requête soit initiée. Cette sous-requête a pour but de générer un hash, que le layer de cache va utiliser pour faire varier le cache. Le hash qui est généré, et l’instruction Vary sur ce hash, sont ensuite supprimées de la réponse une fois le traitement effectué par le layer de cache, pour des raisons évidentes de sécurité (on ne veut pas que tout le monde sache le protocole utilisé pour « masquer » du contenu à certains utilisateurs). L’idée est que vous pouvez générer un hash différent en fonction du niveau de droit de l’utilisateur par exemple, pour faire en sorte que, à l’affichage d’une page donnée, le serveur soit en mesure de servir depuis le cache un contenu différent en fonction du groupe de l’utilisateur.

Ce fonctionnement est intéressant car il apporte un avantage significatif par rapport au Vary « classique ». On pourrait simuler un comportement similaire en utilisant Vary: Cookie en header de réponse HTTP, mais cela signifierait que l’on va générer un nouveau cache par utilisateur (on ne peut pas préciser sur quel cookie on veut varier la réponse). Grâce à la fonctionnalité de FOSHttpCacheBundle, nous allons pouvoir ne générer qu’une seule fois le cache pour tous les utilisateurs qui partagent un trait commun.

Tout ça fonctionne bien, et notamment dans le cas d’utilisation où on affiche un contenu différent en fonction du niveau de droit de l’utilisateur. En l’occurrence dans notre projet, nous avons plusieurs informations, stockées en cookie, que nous utilisons pour varier le contenu affiché. Notamment, l’utilisateur peut choisir une monnaie de navigation pour afficher tous les prix dans cette monnaie. Cette information est stockée dans un cookie, et nous utilisons la fonctionnalité User Context de FOSHttpCache pour varier le contenu du cache en fonction de la monnaie sélectionnée.

Le problème que nous avons rencontré est apparu lors du scénario suivant :

  • L’utilisateur se rend sur une page où sont affichés des prix
  • L’utilisateur change sa monnaie de navigation, ce qui rafraîchit la page
  • Les prix sur la page sont inchangés
  • L’utilisateur fait F5: la page se recharge avec les prix dans la nouvelle monnaie

Le contenu de cette page est inchangé tant que l’utilisateur n’a pas fait F5. Cette page a été configurée avec un max-age > 0, ce qui veut dire que l’on souhaite que le client mette en cache le contenu de la page. A la deuxième visualisation de la page, après avoir changé de monnaie de navigation, c’est le cache navigateur qui est utilisé pour afficher le contenu. Or le navigateur n’a pas conscience du fait que le contenu doive varier en fonction d’un hash, car on a supprimé cette information de la réponse. Il n’a pas connaissance du hash et ne peut pas varier son cache en fonction. Faire F5 force le navigateur a ne pas utiliser son cache et faire une requête, qui tombe chez Varnish, qui utilise le hash et renvoie le bon contenu.

Nous avons résolu le problème en modifiant la configuration des headers de cache pour renvoyer un max-age égal à 0 (pas de cache navigateur) sur les pages impliquées.

Cependant, ce n’est pas tout ! Votre navigateur n’est pas le seul à mettre en cache le contenu. Par exemple, beaucoup d’entreprises ont un serveur proxy qui interceptent les réponses HTTP servies à des clients dans leur réseau et peuvent potentiellement mettre en cache le contenu. Ces proxy vont utiliser non pas le header max-age, mais le header s-max-age, le même que votre serveur Varnish. Dans ce cas là, on risque de rencontrer le même problème : un utilisateur de ce réseau accède à une page avec contenu contextualisé sur votre application, génère le cache côté serveur, mais aussi côté proxy. Le proxy ne sait pas qu’il doit varier sur un hash dont il n’a pas connaissance, et met en cache le contenu, qu’il va reservir à tous les utilisateurs du réseau. Ici la solution de mettre le header s-max-age à 0 ne fonctionne pas, car cela signifierait que vous laissez tomber l’idée de mettre en cache votre contenu, y compris sur votre serveur Varnish !

La solution que je vais vous recommander est plus générale et vaut pour d’autres cas d’utilisation :

A partir du moment où un cache est géré du côté de votre application ou votre serveur de manière générale, et que l’information ne peut pas être transmise au client ou aux éventuels proxy entre votre serveur et votre client, il faut surcharger les headers de cache de votre réponse pour forcer un private, must-revalidate, no-cache après traitement du cache par votre serveur.

Cela est valable pour ce type de cache contextualisé, mais aussi à partir du moment où vous utilisez des ESI. En effet, les ESIs sont interprétés par votre serveur de cache pour être remplacés par du contenu HTML dans la réponse. Les clients et les proxy n’ont pas connaissance de ces ESIs, et vont mettre en cache le contenu HTML résultant du traitement de votre serveur de cache. Vous devez également forcer un header no cache dans ce cas là pour éviter que du contenu soit mis en cache par des serveurs ou des clients qui ne disposent pas des informations pour faire ce travail correctement.

La couche HTTPCache de Symfony fait ce travail pour vous quand votre page contient des ESIs. Mais si vous utilisez Varnish, c’est à vous de configurer convenablement votre serveur.

Dans le cas du User Context de FOSHttpCache, je vous recommande cette configuration Varnish :

sub vcl_deliver {
  if (resp.http.Vary ~ "X-User-Context-Hash") {
    if (resp.http.X-Cache-Debug) {
      set resp.http.X-Original-Cache-Control = resp.http.Cache-Control;
    }
    set resp.http.Cache-Control = "must-revalidate, no-cache, private";
  }
}

Cette configuration surcharge le header Cache-Control et ajoute le header Cache-Control original pour faciliter le debug (si vous voulez facilement voir quel était le header pris en compte par Varnish avant surcharge).

Dans le cas des ESIs, c’est moins simple. A ma connaissance il n’est pas possible de savoir si des ESIs ont été processés ou non par Varnish. On doit donc compter sur l’application pour setter un header si un ESI est présent dans la réponse, ou se baser sur un header que l’on initialise dans la VCL de Varnish si on gère des ESIs :

sub vcl_fetch {
  if (beresp.http.Surrogate-Control ~ "ESI/1.0") {
    set beresp.do_esi = true;
    set resp.http.X-ESI = true;
  }
}

sub vcl_deliver {
  if (resp.http.X-ESI || resp.http.Surrogate-Control ~ "X-User-Context-Hash") {
    if (resp.http.X-Cache-Debug) {
      set resp.http.X-Original-Cache-Control = resp.http.Cache-Control;
    }
    unset resp.http.X-ESI;
    set resp.http.Cache-Control = "must-revalidate, no-cache, private";
  }
}

De manière générale, si vous faites un traitement côté serveur pour générer du cache de manière transparente pour le client, modifiez les headers de cache en fonction. Il est préférable de ne pas mettre du contenu en cache chez le client ou chez les proxy entre votre serveur et vos clients plutôt que de servir le mauvais contenu à la mauvaise personne.

Les configuration proposées ici n’empêchent pas la mise en cache, elles s’assurent simplement que la mise en cache n’est faite que par le parti qui a toutes les informations nécessaires pour faire ce travail correctement.

par Christian

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Rechercher