Pensées sur les CDN d'images

Publié le 31/07/2024

Pour accélérer le chargement d’un site web, réduire la quantité de données transférées, et livrer un format d’image adapté aux appareils utilisés, il est d’usage d’avoir recourt à des services qu’on appelle souvent “image CDN”.

Ces services “de CDN d’images” réalisent, en interne l’encodage à la volée d’une image source vers un format, une qualité, et une résolution spécifique spécifiées dans l’URL. Ces services intègrent possiblement une politique de cache des images générées.

État de l’art

Dans ce domaine, on peut recenser de nombreux acteurs SaaS comme Netlify Image CDN, KeyCDN Image Processing, Cloudflare Images ou encore Akamai Image & Video Manager. Il existe aussi des solutions à héberger soi-même, comme imgproxy, imaginary, thumbor, pilbox, imageproxy ou encore picfit. Enfin, on peut construire ce genre de services via des bibliothèques dédiées comme sharp en NodeJS, qui se base sur la bibliothèque C libvips qui a des bindings dans la plupart des langages.

Défis techniques

Pour tout service informatique se pose des questions de deux ordres : fonctionnel et opérationnel. Le périmètre fonctionnel est bien défini, pour preuve l’homogénéité de fonctionnement de ces services. On peut au besoin se baser sur l’image API 3.0 de l’IIF si on veut.

L’aspect opérationnel quant à lui revêt des défis non triviaux, spécifiquement quant on a une approche computing within limits. En effet, la conversion d’une image n’est pas une opération négligeable en terme de consommation de CPU & RAM. À celà s’ajoute deux pré-requis particulièrement fort liés à l’aspect “à la volée” du service : 1) la conversion doit être réalisée de manière “intéractive” et 2) l’arrivée des requêtes n’est pas prédictible ou uniformément dispersée.

On peut avoir un premier aperçu des enjeux liés à ce service à travers un benchmark, réalisé vers 2019 - il y a 5 ans à l’écriture de ce billet - par un dévelopeur d’une de ces solutions, et intitulé imgproxy vs alternatives benchmark. Le test consiste à redimensionner une image JPEG de 29Mo pour une résolution de 7360x4912 (typiquement une photo prise par un appareil photo réflexe) vers une résolution de 500x500, toujours en JPEG. Le benchmark semble être configuré avec 4 requêtes en parallèle. imgproxy, thumbor, et imaginary se démarquent particulièrement des autres logiciels par leurs bonnes performances : environ 10 images par secondes, entre 200Mo et 400Mo de mémoire vive consommées, autour de 500ms de processing par image.

Ces chiffres sont loins d’être anodins : étant donné la nature du test, il est raisonable de penser que l’image se trouve dans le cache en mémoire vive. Les 500ms de processing sont donc dus uniquement aux accès mémoires et à la logique de redimenssionnement, et non à l’attente d’entrées-sorties. Autrement dit, la conversion d’une seule image génère un pic de CPU à 100% pendant 500ms.

Par contre, ce test ne nous dit rien des formats d’images plus récents comme AVIF, HEIC ou même WebP. Si ces formats génèrent des fichiers de plus petites tailles, ils sont aussi connus pour demander d’avantage de ressources CPU. En pratique, cela risque d’amplifier encore le temps d’encodage, particulièrement si l’image générée a une haute résolution.

Enfin, le domaine des tests de performance est grand. Ce “benchmark” tombe sous le coup du “test de charge” : on envoie 4 requêtes parallèles en continu et on observe comment le système se comporte. Mais quid d’un “stress test”, qui dépasse les limites du système, et qui nous permet de voir comment ce dernier se comporte, et comment il recover ?

En effet, que ce soit par maladresse ou par malveillance, il est certain qu’un tel système basé sur des “traitements à la volée” fera rapidement face à des charges de travail qu’il ne pourra pas traiter en temps acceptable (supposons 5 secondes). Que ce soit des images très hautes résolutions de la voute céleste, une grille de miniatures générant 60 images en parallèle, un pic de trafic soudain sur un site web suite à un partage sur les réseaux sociaux, ou quelqu’un de malveillant générant des requêtes volontairement intensives en ressource.

Failure mode

À mon sens, il n’existe aucune autre solution que la conception d’un failure mode. Lorsque qu’une trop grande charge de travail est envoyée au service, ce dernier passe en failure mode le temps d’absorber la charge. Une fois la charge absorbée, le service recover et repasse dans son mode normal. Ce failure mode doit forcément être très efficace, sinon il ne sert à rien.

On peut d’abord envisager un mode de fonctionnement très direct pour notre failure mode : envoyer un code d’erreur HTTP, comme le standard 503 service unavailable ou le non-standard 529 service overloaded.

Plus ambitieux, on peut envoyer une image placeholder à la place, sans directive de cache bien entendu, ce qui permettrait de donner une indication visuelle plus claire aux internautes, et potentiellement de moins casser le site web. Cette image placeholder serait pré-calculée au démarrage du service pour tous les formats supportés (JPEG, HEIC, etc.) et stockée en mémoire vive.

Se pose encore la question de la taille : si on envoie une taille différente de celle attendue, on peut “casser” le rendu du site. À contrario, générer une image à la bonne taille à la volée demande des calculs, bien que si on complète avec une couleur uniforme, ces calculs puissent possiblement être triviaux en fonction du format considéré.

Enfin, le problème majeur, c’est que les images sont intégrées de pleins de manières différentes à travers un site web, parfois mélangées avec des filtres : comment s’assurer que notre placeholder sera correctement reçu et compris ?

Dans le cadre du développement d’une première itération, la solution des codes d’erreur semble préférable.

Files d’attente

Reste maintenant à définir comment on bascule dans ce failure mode. Et pour se faire, on va partir de conceptions single-thread et multi-thread naïves pour comprendre comment elles échouent. En single-thread, lorsque plusieurs requêtes seront reçues, elles vont s’accumuler soit dans le noyau, soit dans le runtime (eg. nodejs) et une seule sera processée (car on suppose un processus CPU bound sans IO). Les requêtes vont donc s’accumuler, quelques unes vont être process, mais la plupart vont timeout. En multi-thread, on va progresser sur la conversion de plusieurs requêtes en parallèle mais très lentement à chaque fois, au point qu’on va aussi timeout probablement. Dans le cas du multi-thread, on risque aussi d’épuiser les ressources du serveur.

À la place, on va placer les traitements d’image dans un ou plusieurs fils dédiés mais toujours un nombre inférieur à notre nombre de CPU, pour garder un serveur réactif. Lorsqu’on veut réaliser un taitement, on place notre requête dans une file d’attente. Lorsqu’un fil a fini son traitement, il prend un nouveau job dans cette file d’attente. Cette file d’attente est bornée, elle peut donc être pleine, auquel cas on passe dans le failure mode tant qu’elle ne s’est pas vidée. Ici, on a formulé notre problème selon un modèle académique bien connu, et surlequel on peut envisager itérer.

Une des questions qui se pose est bien entendu “quelle est la bonne borne pour la file d’attente” ? On peut commencer par mettre des valeurs statiques, qui seraient configurées de manière empirique en fonction du type de déploiement. On peut être tenté ensuite de calculer aussi combien de temps va prendre la file d’attente à être traitée, en fonction du type de job (format, taille de l’image, etc.) et des performances passées : ça semble compliqué et hasardeux. À la place, on peut imaginer une gestion inspirée de CoDel : une file d’attente est utile si elle permet d’absorber des burst sur une courte période, sinon elle est néfaste. On peut donc définir cette courte période : par exemple 5 secondes. Si durant cette période, la file d’attente n’a jamais été vide ou presque (mettons qu’aucune image n’a été traitée en moins de 500ms), alors on est en sur-capacité, on doit passer en failure mode et “drop” certains traitements. Il y aurait quelques ajustements à réaliser pour que ça fonctionne - par exemple imposer un temps de traitement maximal par image, ici ce serait 500ms aussi.

Dans le cadre du développement d’une première itération, on peut se contenter d’une valeur statique.

Cache

Bien entendu, un tel système s’entend aussi avec un cache, qui pose son lot de questions : comment on le garbage collect ? est-ce qu’on met une taille maximale à ce dernier ? qu’est-ce qu’on fait si on la dépasse ? On peut voir aussi des synergies entre notre système de fil d’attente et de cache : on pourrait imaginer une seconde file d’attente avec une plus longue période (mettons 2 heures), encaissant donc de plus gros bursts, qui fonctionnerait de manière asynchrone pour hydrater le cache. Les éléments qui ne peuvent pas être ajoutés à la file d’attente synchrone pourraient être ajoutés à la 2nde file d’attente. Ça fonctionnerait particulièrement bien avec les galeries : si il est impossible de générer 60 miniatures au chargement de la page, ces miniatures pourraient être générées en asynchrone pour plus tard.

Idéalement, le cache serait imputé par utilisateur-ice, directement dans leur bucket. L’expiration des objets seraient réalisée via le système de Lifecyle de S3 (non-implémenté dans Garage à ce jour). Avec les lifecycles, il est trivial d’implémenter un pseudo FIFO en expirant tous les objets X jours après leur création, mais moins évident de faire un LRU ou LFU. Sans considérer les lifecycles ni l’imputation par bucket, on peut imaginer une stratégie différente. En utilisant un seul bucket (par instance), on définirait un nombre fixé de “slots”, par exemple 1 000, correspondant à une clé cache0 à cache999. Un mapping entre la clé de cache et l’URL de l’image (son identifiant, sa taille, etc.) est maintenu en mémoire et est régulièrement flush, c’est l’index. Ce dernier contient aussi la date de dernier accès, et toute autre information utile/importante pour la stratégie d’eviction du cache. Il se peut que la clé de cache et l’index se désynchronise, afin d’éviter d’envoyer une donnée “corrompue”, on vérifie que l’ETag stocké dans l’index correspond à celui de l’objet. Afin d’éviter une explosion du stockage, on met aussi une borne supérieure sur la taille de ce qui peut être stocké dans le cache. Par exemple, avec une borne à 5Mo et 1000 fichiers, notre cache ne dépassera pas 5Go. Enfin, on peut suivre l’efficacité de notre cache en trackant des métriques bien connus sur ce dernier (cache hit, cache miss, etc.).

Si on pourrait être tenté dans une première itération de ne pas utiliser S3 pour le cache mais le filesystem ou la mémoire vive, je pense que c’est une erreur. Si le CDN se reschedule sur un autre noeud, on perd le cache, et on risque de passer trop souvent dans le failure mode inutilement, créant du désagrément et de l’incompréhension pour rien auprès des utilisateur-ices.

On peut aussi être tenté d’utiliser des outils de caching existants plutôt que de ré-implémenter notre propre politique de cache. D’abord ça n’est pas évident que ce soit possible dans notre cas d’usage où on a besoin de stocker dans S3. Ensuite, ça nous rendrait impossible l’implémentation ultérieure de l’imputation du stockage à l’utilisateur final.

Conclusion

Dans ce billet de blog, on a vu que la conversion et redimensionnement des images à la volée consommait beaucoup de ressources CPU & RAM. De ce fait, c’est un défi à mettre en oeuvre dans un environnement contraint en ressources (computing within limits). En s’autorisant un failure mode, on peut cependant s’assurer d’une certaine résilience du système face à des pics de charge trop importants, et donc assurer la viabilité d’un tel service. La théorie des fil d’attentes et CoDel sont un exemple de comment & quand basculer entre le normal mode et le failure mode. Enfin, un système de cache bien conçu permettrait une réduction significative de l’utilisation CPU+RAM pour un coût supplémentaire en stockage modique. Idéalement, le coût supplémentaire en stockage serait imputé à l’utilisateur ; on peut aussi envisager utiliser le cache pour un traitement asynchrone des images, comme la génération d’un grand nombre de miniatures qui ne peut pas être fait de manière synchrone en environnement contraint.