L'extase et l'agonie des caches

Durant de nombreuses années de construction de services chez Amazon, nous avons expérimenté différentes versions du scénario suivant : nous construisons un nouveau service, qui doit effectuer quelques appels réseau pour traiter ses requêtes. Il peut s'agir d'appels à une base de données relationnelle, à un service AWS comme Amazon DynamoDB ou à un autre service interne. Lors de tests simples ou à des taux de requête faibles, le service fonctionne parfaitement, mais nous remarquons qu'un problème se profile. Ce dernier peut être lié à des appels lents à cet autre service ou à un coût élevé de la mise à l'échelle de la base de données lorsque le volume d'appels augmente. Nous remarquons également que de nombreuses requêtes utilisent la même ressource en aval ou les mêmes résultats de requête et pensons donc que la mise en cache de ces données pourrait être la solution à nos problèmes. Nous ajoutons un cache et notre service semble bien mieux fonctionner. Nous remarquons que la latence des requêtes est faible, que les coûts sont réduits et que les petits chutes de disponibilité en aval se sont arrangées. Après quelques temps, personne ne se souvient de la vie avant le cache. Les dépendances réduisent les tailles de leurs flottes en conséquence et la base de données est revue à la baisse. Alors que tout semble aller pour le mieux, le service est en passe de vivre une catastrophe. Nous pourrions découvrir des changements dans les modèles de trafic, un échec de la flotte de cache ou d'autres circonstances inattendues pouvant entraîner un cache froid ou indisponible pour une autre raison. Les conséquences seraient un pic de trafic aux services en aval et donc des pannes à la fois dans nos dépendances et dans notre service.

Nous venons de décrire un service qui est devenu dépendant de son cache. Le cache a été élevé par inadvertance de supplément utile au service à un élément nécessaire et indispensable de sa capacité à fonctionner. Au cœur de ce problème se trouve le comportement modal introduit par le cache, avec un comportement variable selon si un objet donné est mis en cache ou non. Un déplacement inattendu de la distribution de ce comportement modal peut potentiellement conduire à la catastrophe.

Nous avons découvert à la fois les avantages et les difficultés de la mise en cache lors de la construction et de l'exploitation de services chez Amazon. La suite de cet article décrit les leçons que nous avons apprises, nos bonnes pratiques et les éléments à prendre en compte lors de l'utilisation de caches.

Lorsque nous utilisons des caches

Plusieurs facteurs nous amènent à réfléchir à l'ajout d'un cache à notre système. L'élément déclencheur est souvent une observation sur la latence ou l'efficacité d'une dépendance à un taux de requête donné, par exemple lorsque nous déterminons qu'une latence commence peut-être à limiter sa charge ou à ne plus suivre la charge anticipée. Nous jugeons utile d'envisager la mise en cache lorsque nous rencontrons des modèles de requête irréguliers qui entraînent des limites clé/partition les plus actives. Les données de cette dépendance seraient parfaites pour la mise en cache si ce cache avait un bon taux de succès dans les requêtes. En d'autres termes, les résultats des appels vers la dépendance peuvent être utilisés dans plusieurs requêtes ou opérations. Si chaque requête nécessite généralement une requête unique vers le service de dépendance avec des résultats qui lui sont propres, le cache a un taux de succès négligeable et ne sert à rien. Un deuxième élément à prendre en compte est la tolérance du service d'une équipe et de ses clients face à une cohérence éventuelle. Les données mises en cache augmentent progressivement sans cohérence avec la source, et c'est pourquoi la mise en cache ne peut fonctionner que si le service et ses clients compensent la situation en conséquence. Le taux de variation des données sources, ainsi que la stratégie de mise en cache pour rafraîchir les données, détermineront le degré d'incohérence général des données. Ces deux aspects sont liés. Par exemple, des données relativement statiques ou peu variables peuvent être mises en cache pour des périodes plus longues.

Caches locaux

Les caches de services peuvent être implémentés soit en mémoire soit de manière externe au service. Les caches on-box sont communément implémentés dans la mémoire de traitement, sont relativement rapides et faciles à implémenter et peuvent apporter des améliorations significatives avec peu d'effort. Les caches on-box sont souvent la première approche mise en œuvre et évaluée lorsque des besoins de mise en cache sont identifiés. Contrairement aux caches externes, ils ne s'accompagnent pas de frais opérationnels supplémentaires. On ne court donc pas vraiment de risque en les intégrant à un service existant. On intègre bien souvent un cache on-box en tant que table de hachage en mémoire gérée par une logique d'application (par exemple, en plaçant explicitement les résultats dans le cache une fois les appels de service terminés) ou intégré dans le service client (par exemple, en utilisant un client HTTP de mise en cache).

Malgré les avantages et l'attrait de la simplicité des caches en mémoire, ils présentent plusieurs inconvénients. L'un d'entre eux est que les données en cache sont incohérentes d'un serveur à l'autre au sein de sa flotte, en raison d'un problème de cohérence de cache. Si le client effectue des appels répétés au service, il est possible qu'il obtienne de nouvelles données dans le premier appel et d'anciennes données dans le second appel, en fonction du serveur qui traite la demande.

Un autre défaut des caches en mémoire est que le chargement en aval n'est pas proportionnel à la taille du parc de service. Par conséquent, même si le nombre de serveurs augmente, il reste possible que des services dépendants soient submergés. Il a été déterminé qu'une façon efficace de contrôler ce phénomène est de générer des métriques sur les succès/défauts de cache et sur le nombre de demandes effectuées auprès des services en aval.

Les caches en mémoire sont également susceptibles de rencontrer des problèmes de « démarrage à froid ». Ces problèmes surviennent lorsqu'un nouveau serveur est lancé avec un cache totalement vide, ce qui peut entraîner une transmission en rafales de requêtes au service dépendant pendant qu'il remplit son cache. Cela peut constituer un problème majeur lors des déploiements ou dans d'autres circonstances dans lesquelles le cache est vidé à l'échelle du parc. Les problèmes de cohérence du cache et de cache vide peuvent souvent être résolus en recourant à la coalescence des requêtes, processus décrit plus bas dans cet article.

Caches externes

Les caches externes peuvent traiter bon nombre de problèmes que nous avons abordés. Un cache externe stocke les données mises en cache dans une flotte séparée, par exemple, à l'aide de Memcached ou de Redis. Les problèmes de cohérence de cache sont ainsi réduits, car le cache externe conserve la valeur utilisée par tous les serveurs de la flotte. (Notez que le problème n'est pas totalement éliminé, car des pannes peuvent survenir lors de la mise à jour du cache.) La charge globale sur les services en aval est réduite par rapport aux caches en mémoire et n'est pas proportionnelle à la taille de la flotte. Il n'y a pas de problèmes de démarrage à froid lors d'événements comme les déploiements, étant donné que le cache externe se remplit toujours via le déploiement. Enfin, les caches externes fournissent davantage d'espace de stockage disponible que les caches en mémoire, réduisant ainsi les occurrences d'expulsion de cache dues au manque d'espace.

Les caches externes présentent toutefois des inconvénients à ne pas négliger. Le premier est une augmentation de la complexité du système et de la charge opérationnelle générales, puisque nous avons une flotte supplémentaire à surveiller, à gérer et à mettre à l'échelle. Les caractéristiques de disponibilité de la flotte de cache sera différente du service dépendant pour lequel elle sert de cache. La flotte de cache est souvent moins disponible, par exemple lorsqu'elle n'a pas le support pour les mises à niveau sans temps mort et qu'elle nécessite des fenêtres de maintenance.

Nous avons découvert que pour empêcher la dégradation de la disponibilité d'un service à cause d'un cache externe, il faut ajouter un code de service pour traiter la non-disponibilité de la flotte de cache, l'échec d'un nœud de cache ou les échecs d'écriture et de lecture. Une solution consiste à revenir à l'appel du service dépendant, mais nous avons découvert que cette approche était délicate. Pendant un arrêt de cache étendu, cela provoque un pic de trafic inhabituel vers le service en aval, entraînant ainsi une limitation ou une diminution du service dépendant et enfin une réduction e la disponibilité. Nous préférons utiliser le cache externe conjointement avec un cache en mémoire auquel nous pouvons revenir si le cache externe devient indisponible, ou utiliser le délestage et plafonner le nombre maximal de requêtes envoyé au service en aval. Nous testons le comportement du service avec la mise en cache désactivée pour vérifier que les mesures de protection mises en place pour empêcher la diminution des dépendances fonctionnent comme prévu.

Le deuxième élément à prendre en compte est la mise à l'échelle et l'élasticité de la flotte de cache. Lorsque la flotte de cache se rapproche de sa limite de taux de requête ou de mémoire, des nœuds doivent être ajoutés. Nous déterminons quelles métriques sont des indicateurs avancés de ces limites afin de pouvoir définir des moniteurs et des alarmes en conséquence. Par exemple, dans un service sur lequel j'ai récemment travaillé, notre équipe a découvert que l'utilisation du CPU était très élevée lorsque le taux de requêtes Redis atteignait sa limite. Nous avons testé la charge avec différents modèles de trafic réalistes pour déterminer la limite et trouver le seuil d'alarme approprié.

Lorsque nous ajoutons de la capacité à la flotte de stockage, nous veillons à ne pas provoquer de panne ou de perte importante des données en cache. Plusieurs technologies de mise en cache ont leurs propres éléments à prendre en compte. Par exemple, certains serveurs cache ne prennent pas en charge l'ajout de nœuds à un cluster sans temps d'arrêt, et certaines bibliothèques client cache ne fournissent pas de hachage cohérent, ce qui est nécessaire pour ajouter des nœuds à la flotte de cache et redistribuer les données mises en cache. Au vu de la variabilité des implémentations clients d'un hachage cohérent et de la découverte de nœuds dans la flotte de cache, nous testons minutieusement l'ajout et la suppression de serveurs cache avant la mise en production.

Avec un cache externe, nous nous assurons de bien vérifier la robustesse, étant donné que le format de stockage n'est plus le même. Les données mises en cache sont traitées comme si elles se trouvaient dans un magasin permanent. Nous nous assurons que le logiciel mis à jour peut toujours lire les données écrites par l'une de ses versions antérieures et que les anciennes versions peuvent gérer les nouveaux formats/champs en toute simplicité (par exemple, lorsque la flotte dispose d'une combinaison d'ancien et de nouveau code pendant les déploiements). Il est nécessaire d'empêcher les exceptions non détectées en cas de formats inattendus pour éviter les pilules empoisonnées. Cependant, ces précautions ne sont pas suffisantes pour éviter tous les problèmes liés au format. Détecter une incompatibilité de format de version et annuler les données mises en cache peut entraîner un rafraîchissement en masse des caches, provoquant ainsi des limitations et des baisses du service dépendant. Les problèmes de format de sérialisation sont traités plus en détail dans l'article Assurer la sécurité de la restauration pendant les déploiements.

Le dernier élément à prendre en compte pour les caches externes est le fait qu'ils sont mis à jour par des nœuds individuels dans la flotte de service. Les caches n'ont généralement pas de fonctions comme les opérations put et les transactions conditionnelles, c'est pourquoi nous veillons à ce que le code de mise à jour de cache est correct et nous ne laissons jamais le cache dans un état invalide ou incohérent.

Caches en ligne vs côtes caches

Lorsque nous évaluons les différentes approches de mise en cache, nous devons également choisir entre les caches en ligne et les caches indirects. Les caches en ligne, ou caches à lecture immédiate/écriture simultanée, intègrent la gestion des caches à l'API d'accès des données principales. Ainsi, la gestion des caches devient un détail d'implémentation de l'API. Nous pouvons citer comme exemple les implémentations spécifiques aux applications comme Amazon DynamoDB Accelerator (DAX) et les implémentations basées sur les standards comme la mise en cache HTTP (avec un client de mise en cache locale ou un serveur cache externe comme Nginx ou Varnish). Les côtes caches, en revanche, sont des magasins d'objets génériques comme ceux fournis par Amazon ElastiCache (Memcached et Redis) ou des bibliothèques comme Ehcache et Google Guava pour les caches en mémoire. Avec les caches indirects, le code d'application manipule directement le cache avant et après les appels à la source de données, vérifiant les objets mis en cache avant d'effectuer des appels en aval, et mettant des objets dans le cache une fois ces appels terminés.

Le principal avantage des caches en ligne est le modèle d'API uniforme pour les clients. La mise en cache peut être ajoutée, retirée ou réglée sans que la logique client ne soit modifiée. Les caches en ligne retirent également la logique de gestion de cache du code d'application, éliminant ainsi une source de bogues potentiels. Les caches HTTP sont particulièrement attractifs parce qu'ils présentent une panoplie d'options standard telles que les bibliothèques en mémoire, les proxys HTTP autonomes comme ceux mentionnés plus haut et les services gérés comme les réseaux de diffusion de contenu (CDN).

Cependant, la transparence des caches en ligne peut également constituer un inconvénient en matière de disponibilité. Les caches externes font maintenant partie de l'équation de disponibilité pour cette dépendance. Le client ne peut pas compenser un cache temporairement indisponible. Par exemple, si vous avez une flotte Varnish qui met les requêtes en cache à partir d'un service REST externe, si la flotte de mise en cache tombe en panne, votre service considère que la dépendance est elle-même tombée en panne. L'autre inconvénient des caches en ligne et qu'ils doivent être construits dans le protocole ou le service pour lequel ils effectuent la mise en cache. Si aucun cache en ligne n'est disponible pour le protocole, alors la mise en cache en ligne est impossible, sauf si vous souhaitez construire un service proxy ou client intégré vous-même.

Expiration du cache

L'un des aspects les plus complexes de l'implémentation d'un cache est le choix de la taille du cache, de la stratégie d'expiration et de la stratégie d'expulsion appropriées. La stratégie d'expiration détermine la durée pendant laquelle un élément doit rester dans le cache. La stratégie la plus courante utilise une expiration temporelle absolue (c'est-à-dire qu'elle associe une durée de vie, ou TTL, à chaque objet lors de son chargement). La TTL est déterminée en fonction des exigences du client, comme sa tolérance aux anciennes données et le niveau de staticité des données, car les données à changement lent peuvent être mises en cache de manière plus agressive. La taille de cache idéale repose sur un modèle du volume de requêtes estimé et de la distribution d'objets mis en cache à travers ces requêtes. À partir de là, nous calculons une taille de cache qui garantit un taux de succès de cache élevé avec ces modèles de trafic. La stratégie d'expulsion détermine la méthode de suppression des éléments du cache lorsque sa capacité maximale est atteinte. La stratégie d'expulsion la plus courante est LRU (moins récemment utilisé).

Jusqu'ici, il ne s'agit que d'un simple exercice de réflexion. Les modèles de trafic réels peuvent différer de ce que nous modélisons, c'est pourquoi nous surveillons les performances réelles de notre cache. Nous préférons le faire en émettant des métriques de service sur les succès et les défauts de cache, la taille de cache totale et le nombre de requêtes aux services en aval.

Nous avons compris qu'il fallait bien réfléchir à la taille du cache et à la stratégie d'expiration. Il convient d'éviter les cas où le développeur choisit une taille de cache et des valeurs TTL de manière aléatoire lors de l'implémentation initiale et ne revient jamais sur ses choix pour les adapter à la situation. Nous avons vu des cas concrets où cette absence de suivi jusqu'au bout a entraîné des pannes de service temporaires et des exacerbations de pannes en cours.

Un autre modèle que nous utilisons pour améliorer la résilience lorsque les services en aval sont indisponibles consiste à utiliser deux TTL : une TTL soft et une TTL hard. Le client tentera de rafraichir les éléments mis en cache en fonction de la TTL soft, mais si le service en aval est indisponible ou ne répond pas à la requête pour une autre raison, les données de cache existantes continueront d'être utilisées jusqu'à ce que la TTL hard soit atteinte. Un exemple de ce modèle est utilisé dans le client AWS Identity and Access Management (IAM).

Nous utilisons également l'approche TTL soft et hard avec une contre-pression pour réduire l'impact des baisses de tension des services en aval. Le service en aval peut répondre avec un événement de contre-pression en cas de baisse de tension, signalant ainsi que le service appelant devrait utiliser des données mises en cache jusqu'à la TTL hard et n'effectuer des requêtes que pour les données qui ne se trouvent pas dans son cache. Nous poursuivons ainsi jusqu'à ce que le service en aval retire la contre-pression. Ce modèle permet au service en aval de récupérer d'une baisse de tension tout en maintenant la disponibilité des services en amont.

Autres éléments à prendre en compte

Un élément important à prendre en compte est le comportement du cache lorsque des erreurs sont reçues par le service en aval. Il est possible de traiter ce problème en répondant aux clients avec la dernière bonne valeur mise en cache, par exemple en utilisant le modèle de TTL soft et hard décrit précédemment. Il est également possible de mettre en cache la réponse d'erreur (c'est-à-dire d'utiliser un « cache négatif ») en utilisant une autre TTL que les entrées de cache positives et propager l'erreur au client. Nous choisissons une approche dans une situation donnée en fonction des particularités du service et du meilleur moment pour que les clients voient les anciennes données plutôt que les erreurs. Quelle que soit l'approche choisie, il est important de veiller à ce que le cache contienne quelque chose en cas d'erreur. Si ce n'est pas le cas et que le service en aval est temporairement indisponible ou incapable de remplir certaines requêtes (par exemple, lorsqu'une ressource en aval est supprimée), le service en amont continue de le bombarder de trafic, pouvant ainsi entraîner une panne ou exacerber une panne existante. Nous avons vu des cas concrets où une mise en cache de réponses négatives échouée entraînait une augmentation du taux d'échecs et des pannes.

La sécurité constitue un autre élément important de la mise en cache. Lorsque nous introduisons un cache dans un service, nous évaluons et nous minimisons les risques de sécurité supplémentaires qu'il implique. Par exemple, les flottes de mise en cache externe manquent souvent de chiffrement pour la sécurité des données sérialisées et du transport. Ce chiffrement est particulièrement important si des informations sensibles sur l'utilisateur sont conservées dans le cache. Ce problème peut être minimisé à l'aide d'outils comme Amazon ElastiCache for Redis, qui prend en charge le chiffrement en transit et au repos. Les caches sont également sensibles aux attaques d'empoisonnement : une vulnérabilité du protocole en aval permet à un attaquant de remplir un cache avec une valeur qu'il contrôle. L'impact de l'attaque est alors amplifié, étant donné que toutes les requêtes effectuées pendant que cette valeur se trouve dans le cache verront la valeur malveillante. Pour donner un dernier exemple, les caches sont également sensibles aux attaques temporelles par canal auxiliaire. Les valeurs de cache sont renvoyées plus rapidement que les valeurs non mises en cache, ce qui permet à l'attaquant d'utiliser le temps de réponse pour obtenir des informations sur les requêtes que d'autres clients ou éléments sont en train d'effectuer.

Le dernier élément à prendre en compte sont les situations de « thundering herd », où plusieurs clients effectuent des requêtes qui nécessitent plus ou moins en même temps la même ressource en aval non mise en cache. Cette situation peut se produire lorsqu'un serveur apparaît et rejoint la flotte avec un cache local vide. Ainsi, de nombreuses requêtes de chaque serveur vont dans la dépendance en aval, entraînant une limitation ou une baisse de tension. Pour pallier ce problème, nous utilisons la coalescence des requêtes : les serveurs ou le cache externe garantissent qu'une seule requête en attente est disponible pour les ressources non mises en cache. Certaines bibliothèques de mise en cache fournissent un support pour la coalescence des requêtes, de même que certains caches en ligne externes (comme Nginx et Varnish). En outre, la coalescence des requêtes peut être implémentée à partir des caches existants. 

Bonnes pratiques Amazon et éléments à prendre en compte

Nous avons abordé jusqu'ici plusieurs bonnes pratiques Amazon ainsi que les inconvénients et les risques associés à la mise en cache. Voici un résumé des bonnes pratiques Amazon et des éléments à prendre en compte recommandés par nos équipes lors de l'introduction d'une mise en cache :

• Assurez-vous qu'il y a bien un besoin légitime de mise en cache, justifié en matière d'amélioration des coûts, de la latence et/ou de la disponibilité. Assurez-vous que les données peuvent être mises en cache, c'est-à-dire qu'elles peuvent être utilisées dans plusieurs requêtes clients. Méfiez-vous de la valeur apportée par le cache et évaluez attentivement si les avantages compensent les risques supplémentaires introduits par le cache.
• Prévoyez d'exploiter le cache avec la même rigueur et les mêmes processus que ceux du reste de la flotte et de l'infrastructure du service. Ne sous-estimez pas ce travail. Émettez des métriques sur l'utilisation du cache et sur les taux de succès pour vous assurer que le cache est correctement réglé. Surveillez les indicateurs clés (comme le CPU et la mémoire) pour vous assurer que la flotte de mise en cache externe est en bon état et correctement mise à l'échelle. Définissez des alarmes pour ces métriques. Assurez-vous que la flotte de mise en cache a été mise à l'échelle sans temps d'arrêt ou invalidation massive du cache (c'est-à-dire que le hachage cohérent fonctionne comme prévu).
• Réfléchissez bien à la taille du cache, à la stratégie d'expiration et à la stratégie d'expulsion. Réalisez des tests et utilisez les métriques mentionnées au point précédent pour valider et configurer ces choix.
• Assurez-vous que votre service est résilient en cas de non-disponibilité du cache, notamment lorsque différentes circonstances entraînent une incapacité à répondre aux requêtes à l'aide des données mises en caches. Ces circonstances comprennent les démarrages à froid, les pannes de flotte de cache, les changements de modèles de trafic ou les pannes étendues en aval. Dans de nombreux cas, vous devez échanger une partie de votre disponibilité pour vous assurer que vos serveurs et vos services dépendants ne subissent pas de baisse de tension (par exemple, en délestant, en limitant les requêtes aux services dépendants ou en traitant d'anciennes données). Pour vous en assurer, réalisez des tests de charge avec les caches désactivés.
• Tenez compte des aspects de sécurité dans le maintien des donnés mises en cache, y compris le chiffrement, la sécurité du transport lors des communications avec une flotte de cache externe et l'impact des attaques d'empoisonnement et par canal auxiliaire.
• Concevez le format de stockage pour les objets mis en cache de façon à ce qu'ils puissent évoluer avec le temps (par exemple, utilisez un numéro de version) et écrivez un code de sérialisation pouvant lire les anciennes versions. Méfiez-vous des pilules empoisonnées dans votre logique de sérialisation de cache.
• Déterminez comment le cache gèrera les erreurs et pensez à maintenir un cache négatif avec une TTL distincte. Ne provoquez ou n'amplifiez pas une panne en demandant la même ressource en aval et en rejetant les réponses d'erreur sans cesse.

De nombreuses équipes de service chez Amazon utilisent des techniques de mise en cache. Malgré les avantages apportés par ces techniques, nous choisissons de ne pas intégrer la mise en cache à la légère en raison des inconvénients qui annulent souvent ces avantages. Nous espérons que cet article vous aidera à évaluer la mise en cache sur vos propres services.


À propos des auteurs

Matt est ingénieur en chef sur des projets de développement d'appareils émergents chez Amazon. Il travaille notamment sur les logiciels et services destinés aux appareils grand public de demain. Il a auparavant travaillé sur AWS Elemental et a dirigé l'équipe qui a lancé MediaTailor, un service d'insertion de publicités personnalisées côté serveur personnalisé pour les vidéos en direct et à la demande. En cours de route, il a participé au lancement de la diffusion de la première saison de NFL Thursday Night Football sur PrimeVideo. Avant de travailler chez Amazon, Matt a travaillé 15 ans dans le secteur de la sécurité, y compris chez McAfee, Intel et quelques startups, sur la gestion de la sécurité d'entreprise, les technologies anti-logiciels malveillants et anti-exploit, les mesures de sécurité assistées par matériel et la DRM.

Jas Chhabra est ingénieur en chef chez AWS. Il a rejoint AWS en 2016 et a travaillé sur AWS IAM pendant quelques années avant de rejoindre son poste actuel chez AWS Machine Learning. Avant de travailler chez AWS, il a travaillé chez Intel dans différents rôles techniques liés à l'IoT, l'identité et la sécurité. Ses intérêts du moment sont le machine learning, la sécurité et les systèmes distribués à grande échelle. Il s'est également intéressé par le passé à l'IoT, aux bitcoins, à l'identité et au chiffrement. Il est titulaire d'un master en informatique.

Éviter les solutions de secours dans les systèmes distribués Utiliser le délestage pour éviter la surcharge Exécuter des annulations sûres pendant les déploiements