forked from quentin/quentin.dufour.io
split in sections
This commit is contained in:
parent
3dcb9e3111
commit
5a03d5db77
1 changed files with 13 additions and 1 deletions
|
@ -18,6 +18,8 @@ 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.
|
à 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.
|
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
|
Dans ce domaine, on peut recenser de nombreux acteurs SaaS comme
|
||||||
[Netlify Image CDN](https://docs.netlify.com/image-cdn/overview/),
|
[Netlify Image CDN](https://docs.netlify.com/image-cdn/overview/),
|
||||||
[KeyCDN Image Processing](https://www.keycdn.com/image-processing),
|
[KeyCDN Image Processing](https://www.keycdn.com/image-processing),
|
||||||
|
@ -29,6 +31,8 @@ Il existe aussi des solutions à héberger soi-même, comme [imgproxy](https://i
|
||||||
Enfin, on peut construire ce genre de services via des bibliothèques dédiées comme [sharp](https://sharp.pixelplumbing.com/) en NodeJS,
|
Enfin, on peut construire ce genre de services via des bibliothèques dédiées comme [sharp](https://sharp.pixelplumbing.com/) en NodeJS,
|
||||||
qui se base sur la bibliothèque C [libvips](https://www.libvips.org/) qui a des bindings dans la plupart des langages.
|
qui se base sur la bibliothèque C [libvips](https://www.libvips.org/) qui a des bindings dans la plupart des langages.
|
||||||
|
|
||||||
|
## Défis techniques
|
||||||
|
|
||||||
À tout service informatique, se pose des questions de deux ordres : fonctionnelles et opérationnelles.
|
À tout service informatique, se pose des questions de deux ordres : fonctionnelles et opérationnelles.
|
||||||
Le périmètre fonctionnel est bien défini, pour preuve l'homogénéité de fonctionnement de ces services.
|
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](https://iiif.io/api/image/3.0/#4-image-requests) de l'IIF si on veut.
|
On peut au besoin, se baser sur [l'image API 3.0](https://iiif.io/api/image/3.0/#4-image-requests) de l'IIF si on veut.
|
||||||
|
@ -56,7 +60,9 @@ En effet, que ce soit par maladresse ou par malveillance, il est certain qu'un t
|
||||||
fera rapidement face à des charges de travail qu'il ne pourra pas traiter en temps acceptable (supposons 5 secondes).
|
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.
|
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.
|
||||||
|
|
||||||
À mon sens, il n'existe aucune autre solution que la conception d'un failing mode.
|
## 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 failing mode le temps d'absorber la charge.
|
Lorsque qu'une trop grande charge de travail est envoyée au service, ce dernier passe en failing mode le temps d'absorber la charge.
|
||||||
Une fois la charge absorbée, le service *recover* et repasse dans son mode normal.
|
Une fois la charge absorbée, le service *recover* et repasse dans son mode normal.
|
||||||
Ce *failing mode* doit forcément être très efficace, sinon il ne sert à rien.
|
Ce *failing mode* doit forcément être très efficace, sinon il ne sert à rien.
|
||||||
|
@ -66,6 +72,8 @@ Cette image placeholder serait pré-calculée au démarrage du service pour tous
|
||||||
|
|
||||||
*Dans le cadre du développement d'une première itération, la solution des codes d'erreur semble de loin être la meilleure.*
|
*Dans le cadre du développement d'une première itération, la solution des codes d'erreur semble de loin être la meilleure.*
|
||||||
|
|
||||||
|
## 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çuees, 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.
|
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çuees, 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](https://fr.wikipedia.org/wiki/Th%C3%A9orie_des_files_d%27attente) bien connu, et surlequel on peut envisager itérer.
|
À 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](https://fr.wikipedia.org/wiki/Th%C3%A9orie_des_files_d%27attente) bien connu, et surlequel on peut envisager itérer.
|
||||||
|
@ -74,6 +82,8 @@ Une des questions qui se pose est bien entendu "quelle est la bonne borne pour l
|
||||||
|
|
||||||
*Dans le cadre du développement d'une première itération, on peut se contenter d'une valeur statique.*
|
*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.
|
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](https://docs.aws.amazon.com/AmazonS3/latest/userguide/intro-lifecycle-rules.html) 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.).
|
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](https://docs.aws.amazon.com/AmazonS3/latest/userguide/intro-lifecycle-rules.html) 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.).
|
||||||
|
@ -82,6 +92,8 @@ Idéalement, le cache serait imputé par utilisateur-ice, directement dans leur
|
||||||
|
|
||||||
*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. Ensuite les outils de caching ne sont pas prévus pour notre cas d'usage où on a besoin de stocker dans S3. Enfin, ça nous rendrait impossible l'implémentation ultérieure de l'imputation du stockage à l'utilisateur final.*
|
*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. Ensuite les outils de caching ne sont pas prévus pour notre cas d'usage où on a besoin de stocker dans S3. Enfin, ç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.
|
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).
|
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 l'exemple de CoDel est un exemple de comment & quand basculer entre le *normal mode* et le *failure mode*.
|
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 l'exemple de CoDel est un exemple de comment & quand basculer entre le *normal mode* et le *failure mode*.
|
||||||
|
|
Loading…
Reference in a new issue