Compare commits

..

1 commit

Author SHA1 Message Date
316651825e Actualiser _posts/2024-04-27-capa-web-deuxfleurs.md 2024-04-29 08:25:44 +00:00
10 changed files with 30 additions and 510 deletions

View file

@ -1,3 +1,7 @@
---
kind: pipeline
name: default
steps:
- name: build
image: ruby

View file

@ -1,24 +1,21 @@
GEM
remote: https://rubygems.org/
specs:
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
bigdecimal (3.1.8)
addressable (2.8.1)
public_suffix (>= 2.0.2, < 6.0)
colorator (1.1.0)
concurrent-ruby (1.3.4)
concurrent-ruby (1.2.2)
em-websocket (0.5.3)
eventmachine (>= 0.12.9)
http_parser.rb (~> 0)
eventmachine (1.2.7)
ffi (1.17.0-x86_64-linux-gnu)
ffi (1.15.5)
forwardable-extended (2.6.0)
google-protobuf (4.28.1-x86_64-linux)
bigdecimal
rake (>= 13)
google-protobuf (3.22.0-x86_64-linux)
http_parser.rb (0.8.0)
i18n (1.14.5)
i18n (1.12.0)
concurrent-ruby (~> 1.0)
jekyll (4.3.3)
jekyll (4.3.2)
addressable (~> 2.4)
colorator (~> 1.0)
em-websocket (~> 0.5)
@ -45,28 +42,28 @@ GEM
kramdown-parser-gfm (1.1.0)
kramdown (~> 2.0)
liquid (4.0.4)
listen (3.9.0)
listen (3.8.0)
rb-fsevent (~> 0.10, >= 0.10.3)
rb-inotify (~> 0.9, >= 0.9.10)
mercenary (0.4.0)
pathutil (0.16.2)
forwardable-extended (~> 2.6)
public_suffix (6.0.1)
racc (1.8.1)
rake (13.2.1)
public_suffix (5.0.1)
racc (1.6.2)
rake (13.0.6)
rb-fsevent (0.11.2)
rb-inotify (0.11.1)
rb-inotify (0.10.1)
ffi (~> 1.0)
redcarpet (3.6.0)
rexml (3.3.7)
rouge (4.3.0)
rexml (3.2.5)
rouge (4.1.0)
safe_yaml (1.0.5)
sass-embedded (1.78.0)
google-protobuf (~> 4.27)
rake (>= 13)
sass-embedded (1.58.3)
google-protobuf (~> 3.21)
rake (>= 10.0.0)
terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3)
unicode-display_width (2.5.0)
unicode-display_width (2.4.2)
webrick (1.8.1)
PLATFORMS
@ -81,4 +78,4 @@ DEPENDENCIES
webrick (~> 1.7)
BUNDLED WITH
2.5.9
2.3.26

View file

@ -8,12 +8,12 @@ category: operation
description: Évaluer la capacité d'hébergement de sites webs statiques de Deuxfleurs.
---
De part son côté atypique (de vieux PC de bureau reconvertis en serveurs derrière des connexions FTTH grands publics avec beaucoup de logiciels maisons - tricot, garage, etc.), les usager-es de Deuxfleurs ne savent pas trop quoi attendre en terme de performance. De mon point de vue d'opérateur, c'est dur également d'évaluer les capacités de Deuxfleurs, à part en disant qu'on a pensé notre solution pour mutualiser les usages, et donc que peu de machines puissent servir à beaucoup de monde.
De par son côté atypique (de vieux PC de bureau reconvertis en serveurs derrière des connexions FTTH grands publics avec beaucoup de logiciels maisons - tricot, garage, etc.), les usager-es de Deuxfleurs ne savent pas trop quoi attendre en terme de performance. De mon point de vue d'opérateur, c'est dur également d'évaluer les capacités de Deuxfleurs, à part en disant qu'on a pensé notre solution pour mutualiser les usages, et donc que peu de machines puissent servir à beaucoup de monde.
Commençons par quelques faits : au 27 avril 2024, Deuxfleurs a 8 serveurs (3 à Orsay, 2 à Lilles, 3 à Bruxelles).
Il n'y a que 2 serveurs / 8 qui reçoivent les requetes : en effet, notre configuration IPv4 ne permet pas d'avoir plus d'un répartiteur de charge HTTP par zone géographique, et notre zone géographique belge est encore en chantier. Chaque serveur est connecté en ethernet 1Gb/sec et on a 300Mbps+ entre Proximus et Free (en gros c'est une approximation de notre bande passante sur Internet, même si ça varie en fonction des destinations et du moment observé bien entendu...). Avec 2 répartiteurs de charge, on estime donc à 600Mb/s notre bande passante sur le réseau (2x 300Mb/s), bien entendu après il faut du logiciel qui puisse gérer ça.
Chaque serveur est à peu prêt identique : un ordinateur de bureau milieu de gamme de 2013. Typiquement, en terme de processeur on a du [Intel(R) Pentium(R) CPU G3420](https://ark.intel.com/content/www/fr/fr/ark/products/77775/intel-pentium-processor-g3420-3m-cache-3-20-ghz.html) et entre 8Go et 16Go de RAM par serveur.
Chaque serveur est à-peu-près identique : un ordinateur de bureau milieu de gamme de 2013. Typiquement, en terme de processeur on a du [Intel(R) Pentium(R) CPU G3420](https://ark.intel.com/content/www/fr/fr/ark/products/77775/intel-pentium-processor-g3420-3m-cache-3-20-ghz.html) et entre 8Go et 16Go de RAM par serveur.
Au total, Nomad, un de nos outils de gestion, rapporte un total de 78Go de RAM et 16 CPU (répartis en 8 machines physiques donc).
En terme de stockage, on a 4To de stockage à Lille, 1.5To à Bruxelles, 3To à Orsay. Ça fait seulement 1.5To utilisable par Garage, car on requiert une duplication sur 3 sites pour la robustesse, et Bruxelles n'a que 1.5To (c'est normal, c'est la "zone" en chantier aujourd'hui).

View file

@ -1,227 +0,0 @@
---
layout: post
title: TLS dans un conteneur statique
date: 2024-06-09
status: published
sitemap: true
category: developpement
description: Comment gérer TLS dans un conteneur statique
---
Cet article est motivé par un problème rencontré sur l'interface Guichet de Deuxfleurs.
Au moment d'intéragir avec l'API S3, on a cette erreur suivante :
```
Impossible d'effectuer la modification.
Put "https://garage.deuxfleurs.fr/bottin-pictures/d1e3607f-4b9c-45fa-9e11-ddf8eef48676-thumb": tls: failed to verify certificate: x509: certificate signed by unknown authority
```
Pour comprendre le problème, partons de cet exemple basique en Go :
```go
package main
import (
"net/http"
"log"
)
func main() {
_, err := http.Get("https://deuxfleurs.fr")
if err != nil {
log.Fatal(err)
}
log.Println("Success")
}
```
Ce bloc de code fait une requête HTTPS vers deuxfleurs.fr et log l'erreur si il y en a une, sinon il affiche `Success`.
On va le compiler en statique pour ne pas dépendre de la lib C locale :
```bash
CGO_ENABLED=0 go build main.go
```
Ce qui nous donne bien un executable statique :
```
$ file main
main: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, Go BuildID=ctUIsYsrR2BtpR58vqRU/TI93T6hZlDxMBNqsplsv/QYD-xJaEDyWB0QaX6tSS/cDyvoEdvE3kZpdq8yCs3, with debug_info, not stripped
```
Si on le fait tourner en local, tout se passe bien :
```
2024/06/09 15:54:59 Success
```
Maintenant, puisqu'on nous avons un binaire statique qui ne dépend de rien, on va vouloir créer un conteneur Docker qui ne contient que ce fichier (alors que souvent on embarque une distribution de base comme Debian ou Alpine). Pour se faire, on écrit un Dockerfile avec deux étapes : une qui a les outils pour le build, et une qui ne contient que notre binaire :
```Dockerfile
FROM golang as builder
COPY main.go .
RUN CGO_ENABLED=0 go build -o /main main.go
FROM scratch
COPY --from=builder /main /main
CMD [ "/main" ]
```
On construit & lance le conteneur et... on reproduit l'erreur !
```
$ docker build -t tls-static-go .
$ docker run --rm -it tls-static-go
2024/06/09 14:02:37 Get "https://deuxfleurs.fr": tls: failed to verify certificate: x509: certificate signed by unknown authority
```
Pour comprendre la différence de comportement entre l'intérieur du conteneur et l'extérieur, on peut faire appel à `strace` et voir les fichiers que notre binaire essaie d'ouvrir :
```bash
$ strace -fe openat ./main
...
[pid 9066] openat(AT_FDCWD, "/etc/ssl/certs/ca-certificates.crt", O_RDONLY|O_CLOEXEC) = 7
[pid 9066] openat(AT_FDCWD, "/etc/ssl/certs", O_RDONLY|O_CLOEXEC) = 7
[pid 9066] openat(AT_FDCWD, "/etc/ssl/certs/ca-bundle.crt", O_RDONLY|O_CLOEXEC) = 7
...
```
En général ce fichier est fourni par les distributions, qui à ma connaissance majoritairement se basent sur le travail de Mozilla.
On peut en lire plus à propos de la gestion de ces racines de confiance sur la page dédiée du wiki de Mozilla : [CA/FAQ](https://wiki.mozilla.org/CA/FAQ)
Une façon de faire est donc de copier le fichier de notre builder dans notre conteneur final :
```diff
--- Dockerfile.old 2024-06-09 16:13:18.457415988 +0200
+++ Dockerfile 2024-06-09 16:12:59.494182932 +0200
@@ -4,5 +4,6 @@
FROM scratch
COPY --from=builder /main /main
+COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
CMD [ "/main" ]
```
On rebuild, on relance et... ça marche !
```
$ docker build -t tls-static-go-2 .
$ docker run --rm -it tls-static-go-2
2024/06/09 14:14:30 Success
```
Pour visualiser le contenu d'une image docker, on peut utiliser l'outil [dive](https://github.com/wagoodman/dive) :
![Dive](/assets/images/posts/dive.png)
À gauche, puis à droite, les commandes sont :
```bash
dive tls-static-go
dive tls-static-go-2
```
On voit bien l'ajout du chemin `/etc/ssl/certs/ca-certificates.crt` à droite.
Maintenant, le problème avec les `Dockerfile`, c'est que ce ne sont pas du tout des builds reproductibles ni précis : on ne sait pas quelles versions ou dépendances on embarque. Donc on veut plutôt construire nos conteneurs avec NixOS pour plus de contrôle.
Pour faciliter le packaging Nix, on va générer un fichier `go.mod` :
```bash
go mod init tls-static-go
go mod tidy
```
On peut ensuite créer un fichier `flake.nix` qui est notre équivalent mais plus précis de notre Dockerfile :
```nix
{
description = "TLS Static Golang";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/master";
};
outputs = { self, nixpkgs }:
let
# On configure "le dépôt Nix"
pkgs = import nixpkgs {
system = "x86_64-linux";
};
# On utilise le builder Go intégré à Nix pour construire notre app
tls-static = pkgs.buildGoModule {
pname = "tls-static";
version = "0.1.0";
src = ./.;
vendorHash = null;
CGO_ENABLED = 0;
};
# On construit une image Docker qui ne contient que l'app qu'on a build.
container = pkgs.dockerTools.buildImage {
name = "superboum/tls-static-go";
copyToRoot = tls-static;
config = {
Cmd = [ "/bin/tls-static" ];
};
};
in
{
# Par défaut, sous Linux amd64, on construit le conteneur
packages.x86_64-linux.default = container;
};
}
```
Et ensuite on peut construire / charger / lancer le conteneur Docker :
```
$ nix build
$ docker load <result
Loaded image: superboum/tls-static-go:zvsb5pwz25irhr90x10kpfhgsph5is1s
$ docker run --rm -it superboum/tls-static-go:zvsb5pwz25irhr90x10kpfhgsph5is1s
2024/06/09 14:37:57 Get "https://deuxfleurs.fr": tls: failed to verify certificate: x509: certificate signed by unknown authority
```
Et de nouveau la même erreur, on jette un coup d'oeil avec dive :
![Dive 2](/assets/images/posts/dive-2.png)
On voit qu'on a quelques dépendances nix de tirées (à propos des fuseaux horaires par exemple) mais rien lié aux certificats.
On va donc injecter là aussi ces certificats dans le conteneur, et pour ce faire on réalise la modification suivante :
```diff
--- flake.nix.old 2024-06-10 09:42:48.871184290 +0200
+++ flake.nix 2024-06-10 09:41:48.011959988 +0200
@@ -24,7 +24,10 @@
# On construit une image Docker qui ne contient que l'app qu'on a build.
container = pkgs.dockerTools.buildImage {
name = "superboum/tls-static-go";
- copyToRoot = tls-static;
+ copyToRoot = pkgs.buildEnv {
+ name = "tls-static-env";
+ paths = [ tls-static pkgs.cacert ];
+ };
config = {
Cmd = [ "/bin/tls-static" ];
};
```
Autrement dit, on a créé notre système de fichiers racine en fusionnant le contenu de notre build `tls-static` avec celui du paquet NixOS `cacert`.
On peut ensuite rebuild / load / run le conteneur avec... succès !
```
$ nix build
$ docker load <result
Loaded image: superboum/tls-static-go:02bmar6x0q1gi8b6j5ys6j1l84mn5vwh
$ docker run --rm -it superboum/tls-static-go:02bmar6x0q1gi8b6j5ys6j1l84mn5vwh
2024/06/09 14:45:06 Success
```
Quant à Dive, on voit bien que le bundle de certificat a été correctement injecté au bon endroit :
![Dive 3](/assets/images/posts/dive-3.png)
Et voilà, vous avez une image Docker fonctionnelle, minimaliste, reproductible, avec des dépendances correctement déclarées, et maintenable.

View file

@ -1,107 +0,0 @@
---
layout: post
title: Pensées sur les CDN d'images
date: 2024-07-31
status: published
sitemap: true
category: developpement
description: Pensées sur les CDN d'images
---
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](https://docs.netlify.com/image-cdn/overview/),
[KeyCDN Image Processing](https://www.keycdn.com/image-processing),
[Cloudflare Images](https://www.cloudflare.com/fr-fr/developer-platform/cloudflare-images/)
ou encore [Akamai Image & Video Manager](https://www.akamai.com/products/image-and-video-manager?image-manager-demo=perceptual-quality).
Il existe aussi des solutions à héberger soi-même, comme [imgproxy](https://imgproxy.net/), [imaginary](https://github.com/h2non/imaginary),
[thumbor](https://github.com/thumbor/thumbor), [pilbox](https://github.com/agschwender/pilbox), [imageproxy](https://github.com/willnorris/imageproxy) ou encore
[picfit](https://github.com/thoas/picfit).
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.
## 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](https://iiif.io/api/image/3.0/#4-image-requests) 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](https://gist.github.com/DarthSim) d'une de ces solutions, et intitulé [imgproxy vs alternatives benchmark](https://gist.github.com/DarthSim/9d971d2859f3714a29cf8ce094b3fc55). 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](https://fr.wikipedia.org/wiki/Th%C3%A9orie_des_files_d%27attente) 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](https://en.wikipedia.org/wiki/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](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.).
*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.

View file

@ -1,147 +0,0 @@
---
layout: post
title: Fast CI builds
date: 2024-08-10
status: published
sitemap: true
category: operation
description: Fast CI builds
---
Historically, in the good old Jenkins days,
a CI build would occure in a workspace
that was kept across build. So your previous artifacts
could be re-used if they did not change (for example, `make` would detect
that some files did not change since that last build and thus did not recompile them).
Also it was assumed that all dependencies were directly installed on the machine.
Both of these properties allowed for very fast and efficient builds: only
what changed needed to be rebuilt.
This approach had many shortcomings: stale cache would break builds (or wrongly make it work),
improper dependency tracking would make building on a new machine very hard, etc.
In the end, developers stop trusting the CI that remain broken, bugs start cripling the project and are not noticed,
and finally the codebase becomes unmaintainable.
To avoid these problems, developers started to use a new generation of CI relying on VM (like Travis CI)
or containers (like Drone). All builds start with a fresh environment, often a well-known distribution like Ubuntu.
Then, for each builds, all the dependencies are installed and the build is done from scratch.
Such approach greatly helped developers better track their dependencies and make sure that building their project from scratch remains possible.
However, build times skyrocketted. You can wait more than 10 minutes before running a command that would actually check your code.
And as recommended by many people[^1][^2][^3] the whole build cycle (lint, build, test) shoud remains below 10 minutes to be useful.
To speed-up the CI, various optimizations have been explored. CI sometimes propose some sort of caching API, and when it does not, an object store like S3 can be used.
This cache is used either by directly copying the dependency folder[^4] (for example the `target/` folder for Rust or the `node_modules/` for Node.JS), or through dedicated tools like `sccache`[^5]. In this scenario, fetching/updating the cache involves a non negligible amount of filesystem+network I/O. Another approach relies on providing your own build image that will often be cached on workers. This image can contain your toolchain (for example Rust + Cargo + Clippy + etc.), but also your project dependencies (by copying your `Cargo.toml` or `package.json` file) and pre-fetching/compiling them. This approach still involves some maintenance burden: image must be rebuilt and published each time a dependency is changed, it's project specific, it can easily break, you still do not track correctly your dependencies, etc.
**Can we cache without making our builds fragile?**
## Nix to the rescue
Following our short discussions, the question that surface is wether or not we can cache efficiently without making our build fragile. Ideally, our project would be split in parts compiled in strict isolation, dependencies between parts would be stricly tracked, cache would be kept locally, and new job would only focus on rebuilding the changed components (avoiding steps like restoring cache & co).
That's what Nix can do, at least in a theory. A SaaS CI ecosystem start developping around it with solutions like [Garnix](https://garnix.io/) or [Hercules CI](https://hercules-ci.com/).
But personnaly, I am more interested in FOSS solutions, and thus existing solutions like [Hydra](https://github.com/NixOS/hydra) or [Typhon](https://typhon-ci.org/) seem more baroque. Worse, often a CI system based on Docker is already deployed in your organization (like [Woodpecker](https://woodpecker-ci.org/), [Gitlab Runner](https://docs.gitlab.com/runner/), [Forgejo Actions](https://forgejo.org/docs/v1.20/user/actions/), etc.), and so you didn't really have a choice here: you must use what's already there.
## The Docker way
In the following, I will describe a docker deployment that should be generic enough to be adapted to any Docker-based CI system. It's inspired by my own experience[^6] and a blog post by Kevin Cox[^7].
First, we will spawn a unique `nix-daemon` on the worker, outside of the CI system:
```bash
docker run -i \
-v nix:/nix \
--privileged \
nixpkgs/nix:nixos-22.05 \
nix-daemon
```
Then we will mount this `nix` volume as read-only in our jobs. The job will be able to access the store to run the programs it needs. It can add new things to the store by scheduling builds in the daemon through a dedicated UNIX socket. This approach is called *Multi-user Nix: trusted building*[^8].
```bash
docker run -it --rm \
-e "NIX_REMOTE=unix:///mnt/nix/var/nix/daemon-socket/socket?root=/mnt" \
-e "NIX_CONFIG=extra-experimental-features = nix-command flakes" \
-v nix:/mnt/nix:ro \
-v `pwd`:/workspace \
-w /workspace \
nixpkgs/nix:nixos-24.05 \
nix build .#
```
Note how the nix daemon and the nix interactive instance have a different version. It's possible as, in the interactive instance, we did not mount the daemon store on the default path (`/nix`) but on another one (`/mnt/nix`) and instructed it to use it in the `NIX_REMOTE` environment variable. This point is important as it enables you to decouple the lifecycle of your worker daemons from the one of your projects, which drastically ease maintenance.
## A woodpecker integration
Basically, you want to run your nix-daemon next to your woodpecker agent, for example in a docker-compose. Then, you need to pass specific parameters to your woodpecker agent such that our volume and environment variables are automatically injected to all your builds:
```yml
version: '3.4'
services:
nix-daemon:
image: nixpkgs/nix:nixos-22.05
restart: always
command: nix-daemon
privileged: true
volumes:
- "nix:/nix"
woodpecker-runner:
image: woodpeckerci/woodpecker-agent:v2.4.1
restart: always
environment:
# -- our NixOS / CI specific env
- WOODPECKER_BACKEND_DOCKER_VOLUMES=woodpecker_nix:/mnt/nix:ro
- WOODPECKER_ENVIRONMENT=NIX_REMOTE:unix:///mnt/nix/var/nix/daemon-socket/socket?root=/mnt,NIX_CONFIG:extra-experimental-features = nix-command flakes
# -- change these for each agent
- WOODPECKER_HOSTNAME=i_forgot_to_change_my_runner_name
- WOODPECKER_AGENT_SECRET=xxxx
# -- should not need change
- WOODPECKER_SERVER=woodpecker.example:1111
volumes:
- "/var/run/docker.sock:/var/run/docker.sock"
volumes:
nix:
```
Note that the volume is named `woodpeck_nix` and not `nix` in the woodpacker agent configuration (`WOODPECKER_BACKEND_DOCKER_VOLUMES` environment declaration). It's because our `docker-compose.yml` is in a `woodpecker` folder and docker compose prefixes the created volumes with the name of the deployment, by default the parent folder name. The prefix is not needed elsewhere, as elsewhere, the resolution is dynamically done by compose. But the `WOODPECKER_BACKEND_DOCKER_VOLUMES` declaration is not part of compose, it will be used later by woodpecker when interacting directly with the Docker API.
Then, in your project `.woodpecker.yml`, you can seemlessly use nix and enjoy efficient and quick caching:
```yml
steps:
- name: build
image: nixpkgs/nix:nixos-24.05
commands:
- nix build .#
```
## Limitations
Anyone having access to your CI will have a read access to your nix store.
People will also be able to store data in your `/nix/store`.
Finally, if I remember correctly, there are some attacks to alter the content of a derivation (such that a content in `/nix/store` is not the product of the hashed derivation). In other words, it's mainly a single-tenant solution.
So a great evolution would be a multi-tenant system, either by improving the nix-daemon isolation, or by running one nix-daemon per-project or per-user/per-organization. Today, none of these solutions is possible.
Another limitation is garbage collection: if the nix-daemon can do some garbage collection, none of its policy is interesting for a CI. Mainly, if you activate it, it will ditch everything as it is connected to "no root path" from its point of view. A LRU cache policy would be a great addition. At least, you can manually trigger a garbage collection once your disk is full...
---
[^1]: [How long should your CI take](https://graphite.dev/blog/how-long-should-ci-take). *Various industry resources suggest an ideal CI time of around 10 minutes for completing a full build, test, and analysis cycle. As Kent Beck, author of Extreme Programming, said, “A build that takes longer than ten minutes will be used much less often, missing the opportunity for feedback. A shorter build doesnt give you time to drink your coffee.”*
[^2]: [Measure and Improve Your CI Speed with Semaphore](https://semaphoreci.com/blog/2017/03/16/measure-and-improve-your-ci-speed.html). *Were convinced that having a build slower than 10 minutes is not proper continuous integration. When a build takes longer than 10 minutes, we waste too much precious time and energy waiting, or context switching back and forth. We merge rarely, making every deploy more risky. Refactoring is hard to do well.*
[^3]: [Continuous Integration Certification](https://martinfowler.com/bliki/ContinuousIntegrationCertification.html). *Finally he asks if, when the build fails, its usually back to green within ten minutes. With that last question only a few hands remain. Those are the people who pass his certification test.*
[^4]: [Rust CI Cache](https://blog.arriven.wtf/posts/rust-ci-cache/). *We can cache the build artifacts by caching the target directory of our workspace.*
[^5]: [My ideal Rust workflow](https://fasterthanli.me/articles/my-ideal-rust-workflow). *The basic idea behind sccache, at least in the way I have it set up, it's that it's invoked instead of rustc, and takes all the inputs (including compilation flags, certain environment variables, source files, etc.) and generates a hash. Then it just uses that hash as a cache key, using in this case an S3 bucket in us-east-1 as storage.*
[^6]: I [tried writing a CI](https://git.deuxfleurs.fr/Deuxfleurs/albatros/src/commit/373c1f8d76b11a5638b2a4aa753417c67f0c2e13/hcl/nixcache/builder.hcl) on top of Nomad that would wrap a dockerized NixOS, and also deployed [a Woodpecker/Drone CI NixOS runner](https://git.deuxfleurs.fr/Deuxfleurs/nixcfg/src/commit/ca01149e165b3ad1c9549735caa658efda380cd3/cluster/prod/app/woodpecker-ci/integration/docker-compose.yml).
[^7]: [Nix Build Caching Inside Docker Containers](https://kevincox.ca/2022/01/02/nix-in-docker-caching). *I wanted to see if I could cache dependencies without uploading, downloading or copying them around for each job.*
[^8]: [Untrusted CI: Using Nix to get automatic trusted caching of untrusted builds](https://www.tweag.io/blog/2019-11-21-untrusted-ci/). *This means that untrusted contributors can upload a “build recipe” to a privileged Nix daemon which takes care of running the build as an unprivileged user in a sandboxed context, and of persisting the build output to the local Nix store afterward.*

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

View file

@ -105,7 +105,7 @@ Sur vos documents écrits ou à l'oral, je vous conseille donc de prévoir d'ind
...ça n'est pas si simple d'être référencé (indexé) dans un moteur de recherche. Il y a 2 choses principales à savoir : 1) ça prend du temps et 2) c'est une boite noire. Si tout se passe bien, votre site commencera a être référencé 1 mois après sa publication. Mais parfois, pour des raisons obscures, il peut ne pas être référencé du tout : il faut alors tatonner pour comprendre pourquoi. La compétition au référencement a créé [des métiers du référencement](https://www.onisep.fr/ressources/Univers-Metier/Metiers/charge-chargee-de-referencement-web) qui prétendent connaître les arcanes des moteurs de recherche et vous promettent monts et merveilles. Selon moi, plutôt que d'essayer de tromper le système, il est préférable de suivre les recommendations : 1) avoir d'autres sites web qui font des liens vers le votre, 2) publier du contenu original et de qualité et 3) publier régulièrement du contenu.
Diffuser l'existence de votre site web c'est bien, faire revenir votre public quand vous avez publié quelque chose de nouveau (des nouveaux évènements, une nouvelle actualité, de nouvelles informations, etc.) c'est encore mieux ! Si vous êtes sur les réseaux sociaux (Instagram, Facebook, etc.), vous pouvez annoncer cette mise à jour par ce biais, en mettant un lien. Si les groupes What's App, Signal, Telegram, ou autres sont populaires parmi vos cercles, ça peut être un autre moyen d'atteindre votre public. Aujourd'hui [les newsletters](https://www.radiofrance.fr/franceinter/podcasts/net-plus-ultra/net-plus-ultra-du-vendredi-22-septembre-2023-3758867) font aussi leur grand retour : l'email est loin d'être mort. Si vous faites parti de réseaux, vous pouvez essayer de compter sur eux pour relayer vos informations (office de tourisme, fédérations en tout genre, etc.) à travers leurs propres canaux de communication.
Diffuser l'existence de votre site web ne suffit, il faut aussi faire revenir votre public quand vous avez publié quelque chose de nouveau : des nouveaux évènements, une nouvelle actualité, de nouvelles informations, etc. Si vous êtes sur les réseaux sociaux (Instagram, Facebook, etc.), vous pouvez annoncer cette mise à jour par ce biais, en mettant un lien. Si les groupes What's App, Signal, Telegram, ou autres sont populaires parmi vos cercles, ça peut être un autre moyen d'atteindre votre public. Aujourd'hui [les newsletters](https://www.radiofrance.fr/franceinter/podcasts/net-plus-ultra/net-plus-ultra-du-vendredi-22-septembre-2023-3758867) font aussi leur grand retour : l'email est loin d'être mort. Si vous faites parti de réseaux, vous pouvez essayer de compter sur eux pour relayer vos informations (office de tourisme, fédérations en tout genre, etc.) à travers leurs propres canaux de communication.
Dans un premier temps, il n'est pas forcément nécessaire d'aller aussi loin : simplement mentionner à vos interlocuteurs que vous avez un site web est un bon début, et le reste sera du bonus !
@ -119,15 +119,15 @@ Vous avez maintenant toutes les cartes en main. Il ne vous reste plus qu'à pren
et coucher vos idées sur la papier (ou sur l'écran).
Voici un récapitulatif des éléments auxquels vous devez réfléchir - quelques lignes peuvent suffire :
- Dans quelle stratégie s'inscrit votre projet de site web ?
- Quelle est votre cible (ou quelles sont vos cibles) et que cherchent t'elles selon vous ?
- Quel est votre cible (ou quelles sont vos cibles) et que cherchent t'elles selon vous ?
- Quel format souhaitez-vous adopter pour votre contenu (carte de visite, brochure, catalogue, etc.) ?
- En fonction du format que vous voulez, écrire les blocs de texte qui apparaîtront
- Rassembler des images, si nécessaire, qui serviront à illustrer votre site
- En fonction du format que vous voulez, écrire les blocs de texte qui apparaitront
- Rassembler des images si nécessaire qui serviront à illustrer votre site
- Si vous avez déjà des supports de communication, les rassembler aussi : on pourra les réutiliser pour créer du contenu.
- Notez quelques idées sur comment vous allez faire connaître votre site web
- Réfléchissez au nom de domaine que vous voudriez
Je suis conscient que ça fait beaucoup de choses, mais elles me semblent essentielles pour la réussite de votre projet. Rien ne presse : si vous n'avez pas le temps maintenant, ça peut être plus tard.
Une fois prêt·e, on peut planifier une demi-journée ensemble pour mettre en place la partie technique. Vous pouvez trouver mes informations de contact sur [ma page d'accueil](https://quentin.dufour.io).
Une fois tout ces éléments réunis, on peut planifier une demi-journée ensemble, en physique, pour mettre en place la partie technique. Et au besoin, vous pouvez trouver mes informations de contact sur [ma page d'accueil](https://quentin.dufour.io).