AWS aime beaucoup les armes secrètes (et son monopole) : c’est bon pour son image, et ça la différencie des autres offres Cloud. Mais de temps en temps, AWS libère du code (pour mutualiser les efforts, pas pour faire plaisir à Stallman) et explique des points précis de son architecture.
L’article On-demand Container Loading in AWS Lambda, détail comment ils ont optimisé le temps de téléchargement des images disques utilisées dans leur offre serverless.
Contexte : le monde du Function As A Service
Le FAAS est proche du serverless, et je suis un fan de jargon technicocommercial préfixé en less, le flou et la polémique vont forcément accompagner ce nouveau barbarisme.
Le FAAS permet d’exposer une fonction comme service dans un cloud. Aucun détail d’implémentation ou même d’exécution ne doit perturber l’utilisateur de FAAS.
Le serverless est un poil au-dessous du FAAS, il abstrait totalement la partie exécution du service, en gérant le nombre d’instances, de zéros à suffisamment, avec une facturation à l’usage.
AWS Lambda
La page Wikipédia d’AWS Lambda donne une explication synthétique (avec l’historique des évolutions), qui évite de lire la page officielle et sa tempête d’acronymes en 3 lettres.
Techniquement, une fonction serverless est un bout de code qui sera démarré, puis qui ira dépiler une file d’attente en HTTP en causant JSON. Le protocole est masqué par les bibliothèques fournies, mais pour les curieux (et les langages compilés), il y a de la documentation et un exemple en shell. On est plus proche de Celery que de Django.
La plupart des utilisateurs se contenteront d’écrire leur bout de code métier : une fonction qui prend un JSON et un contexte en entrée, et qui en sortie produira une réponse serialisable en JSON. On range le nécessaire dans une archive zip, et hop, on téléverse.
Les lambdas ont été imaginés comme le liant entre différents services d’une offre Cloud, pour réagir à des évènements. Permettant ainsi à l’utilisateur de brancher son code DANS la machine, pas juste de consommer du service, avec le très classique protocole client-serveur.
Exposer des fonctions lambdas comme API HTTP n’est qu’un des différents usages des lambdas.
Les lambdas sont volatils et démarrés en quantité fluctuante, la persistance devra être assurée par un autre service, capable d’encaisser les pics d’utilisation (comme un pool de connexion pour une base de données relationnelles, ou des services conçus pour la violence comme S3, DynamoDB ou Scylladb).
Les lambdas sont facturés à la milliseconde, alors que les machines virtuelles sont facturées à la seconde). Oui, c’est un coup de comm, cette granularité est bien trop fine, mais ça insiste sur l’importance de la latence pour un lambda.
De l’autre côté du miroir
Vu du côté hébergement, en intégrant la notion de file d’attente, AWS se laisse la possibilité de lancer plein d’instances si le cluster n’est pas chargé (et se vanter de la faible latence), ou laisser s’allonger les files d’attente en attendant que ça se calme. Si le temps de démarrage du lambda est raisonnable, le cout devrait être comparable.
Lambda masque la notion d’image conteneur (par ce qu’au début, il n’en utilisait pas), mais expose quand même la notion de couches des images conteneurs, en permettant de composer une fonction avec une liste ordonnée d’archives Zip.
L’environnement d’exécution des lambdas est massivement multitenant, l’isolation des conteneurs (namespace, cgroup, seccomp, apparmor/SELinux) n’est pas suffisante pour un milieu aussi agressif, il faut donc des micromachines virtuelles. AWS s’est tourné vers les travaux de ChromeOS pour utiliser kvm (le module kernel), mais sans QEMU. Ils se sont mis d’accord pour partager rust-vmm, qui sert de base pour Firecracker, et pour les VMs spécifiques de ChromeOS.
Firecracker peut mettre en pause une VM (comparable au cgroup freezer), ce qui libère le processeur, mais pas la mémoire, permettant d’entasser des fonctions prédémarrées (Provisioned Concurrency en jargon AWS), fort pratique pour gérer des évènements synchrones peu fréquents, mais réactifs.
Firecracker pourra congeler (developer preview pour l’instant) une VM (en pause) et écrire son état (l’état de la VM et le contenu de la RAM).
Il est possible de rebrancher le réseau et l’entropie d’une fonction congelés, avant de la relancer (unpause en VO). Il est donc possible de décongeler une fonction autre part, plusieurs fois, et ainsi obtenir des clones. Criu promet la même chose depuis longtemps pour les conteneurs (ou les simples process). Le pool de fonctions “pret-à-porter” ne consommera pas de RAM, et pourra être centralisé. Curieux de connaitre la différence entre le temps de démarrage à froid et un téléchargement de l’état suivi d’une décongélation, j’imagine qu’un python qui tire de grosses bibliothèques sera plus apte à la décongélation qu’un langage compilé.
Serverless libre
Il n’y a pas encore de réponse évidente, mais tout tourne autour de Kurbernetes avec des évènements spécifiés par le CNCF, avec cloudevents. Tous considèrent que les fonctions sont exposées en HTTP, et qu’une passerelle gèrera les évènements issus d’une file d’attente, le contraire de Lambda.
Tous ces FAAS sont déployables sur un K8s maison ou infogéré, garantissant ainsi une portabilité (multi-cloud en jargon).
Knative se positionne comme standard de fait, poussé par Google, IBM et Redhat.
OpenFunction propose une approche de plus haut niveau, proposant d’utiliser Knative ou des alternatives (toujours basé sur k8s) comme Keda (l’autoscaler poussé par Azure), Dapr (comme Distributed Application Runtime, un sidecar comparable à Istio en plus ambitieux).
Fission propose une approche simple et de bon gout, avec juste Istio comme fantaisie. Leurs efforts se portent sur le démarrage à froid, avec un pool de conteneurs déjà prêt. À vérifier si le pod est en pause pour libérer du CPU. Le principal contributeur de Fission est InfraCloud, une grosse boite indienne, avec des bureaux en Allemagne, Pays-Bas et États-Unis, et un gros fan du CNCF.
OpenFAAS a exploré les alternatives à K8s, pour finalement ne faire plus que du k8s, le tout sous une licence bancale. Il est compliqué pour une simple société de financer ce genre de projet libre, Knative est financé par des gens qui ont d’autres (énormes) sources de revenus, et leur but est de péter (chatouiller) le monopole d’AWS (et de vendre de la VM ou du consulting).
K8s va tirer les runtimes alternatifs, dont les micro VMs comme Kata qui lui va pouvoir tirer les fils de KVM : QEMU, Cloud-Hypervisor et Firecracker (qui lui sait directement causer le containerd).
De l’autre côté du miroir
Les alternatives libres n’ont pas la volonté (ou même la possibilité) d’imposer un framework qui va emballer la définition de la fonction, d’où le choix standard de n’exposer que des services HTTP. Depuis l’époque de la création de Lambda, l’utilisation des CI/CD s’est généralisé, le build des images est moins bourrins, BuildX et ses amis dispensent le développeur d’une partie du tuning de build. Le distroless et les builds en composant les couches des images reprennent les empilements de Zip de Lambda. Une partie de la simplicité de déploiement de Lambda fait maintenant partie des workflows standards de conteneurs.
L’approche tout HTTP permet d’exposer une API simple, qui sera utilisée de manière synchrone (directement appelé par l’utilisateur en HTTP) ou asynchrone (toujours en HTTP, mais via un dépileur interne connecté à un fil d’attente).
En asynchrone, le scaler (contrôle de débit?) se contente d’effectuer des requêtes HTTP et de regarder ce que raconte Prometheus ou le cgroup du conteneur (la consommation de la fonction) pour estimer le bon débit d’évènement pour qu’une instance soit utilisée entièrement, avant de créer de nouvelles instances.
Les offres libres évoquent différentes files d’attente, comme Redis STREAM, Kafa ou NATS.
Décorticage de l’article sur le démarrage au fil de l’eau
L’article est écrit par des gens de chez AWS, lié au projet Lambda. Il faut donc s’attendre à de l’enthousiasme, et à une validation par leur équipe communication/secret industriel. Venant d’AWS, il faut aussi s’attendre à des échelles titanesques (démarrage en pic de 15k conteneurs par seconde, par exemple), que peu d’entreprises approcheront.
Je suis bien content pour eux de leurs chouettes gains en performance, mais c’est plutôt leur choix technique (et les choix écartés), ainsi que leurs erreurs qui m’intéressent.
Images
L’OCI a normalisé le format des images de conteneur : une collection d’archives tar que l’on désarchive les unes sur les autres, avec une convention de nom pour effacer (et non pas écraser) un fichier.
Même si les images OCI ont été imaginées pour des conteneurs classiques (namespace
et ses amis), elles sont utilisables par des machines virtuelles, pour peu que l’on ajoute divers fichiers (comme un kernel et un init).
Le conteneur (classique ou micro VM) n’a pas besoin de lire l’intégralité de son disque pour démarrer le service. D’après les mesures de Lambda, en moyenne, seuls 6% sont nécessaire pour démarrer.
Les conteneurs travaillent au niveau des fichiers, avec un assemblage virtuel, via overlayfs, même si la piste des blocs a été explorée (avec devicemapper).
Des essais ont été faits avec un chargement paresseux (lazy loading en VO), fichier par fichier, par les projets Slacker et Startlight.
Lambda, pour des raisons de sécurité, souhaite utiliser une VM minimaliste, sans drivers autre que virtio, sans actions complexes en kernel space, ou même en root.
Le tricotage du disque doit donc se faire coté hôte, qui sera exposé à l’invité (la machine virtuelle) via un virtio
.
Le débit de création de fonctions est raisonnable, même poussé, testé systématiquement depuis une CI (à chaque git push
, donc), l’utilisateur s’attend à un temps raisonnable de cuisson (baking
en VO).
Il est donc pertinent de préparer les fonctions (et leur image) lors de leur création, pour optimiser au maximum leur temps de démarrage à froid.
Exposer une image dans un conteneur en µVM
Dans l’article, les indices sont tenus : une image en ext4 découpé en tranche, du FUSE, et enfin virtio-blk pour communiquer avec la machine virtuelle.
À la fin de l’article, les auteurs s’excusent du bricolage qui fait deux sauts périlleux dans le kernel, pour finalement (après l’article) se passer de FUSE.
Pour leur version 1, vraisemblablement, leur agent expose avec FUSE un volume avec un seul fichier RAW, qui est confié à la VM via virtio-blk, et depuis la VM, on voit un bloc, qui est montée en ext4.
nbdfuse fait ce genre de chose, en utilisant FUSE pour exposer une image disque dynamique (connecté à un serveur NBD dans ce cas), tout en disant que c’est une mauvaise idée de monter cette image.
It is tempting (and possible) to loop mount the file. However this will be very slow and may sometimes deadlock.
Nbdfuse fait partie de libguestfs, et dit qu’il est possible de lancer un QEMU avec l’image dans FUSE, même si c’est mieux d’utiliser QEMU avec nbd:// . Bref, nbdfuse a un petit souci de confiance en lui.
Lambda est plus enthousiaste sur les performances d’un passage par FUSE, même si ils ont abandonné cette approche.
Les buzzwords utilisés dans l’article indiquent que la version 2 utilise vhost-user, la nouvelle approche de virtio pour communiquer, sans interruption systèmes, sans copie (et si tout se passe bien, sans passer par le processeur si le matériel le permet).
La communication est établie avec une Vsock
, ensuite les messages sont partagés dans un mmap
synchronisé par un ring
. Attention de ne pas confondre avec io_uring
qui fait ce genre de chose entre user-space et kernel-space, avec de bonnes performances, mais pour l’instant des soucis de sécurité.
La communication zero copy sera utilisée dans plusieurs virtio, et il y a du code rust bas niveau pour vhost user backend (le frontend étant dans l’invité) et une partie des virtio à la sauce vhost-user est disponible.
vhost-user est conçu pour repousser très loin les performances des IO, en se branchant sur les ambitieux projets SPDK (Storage Performance Development Kit) et DPDK) (Data Plane Development Kit), conçues pour profiter des accélérations matérielles et l’univers RDMA : SmartNIC, NVMeOF…
vhost-user permet d’avoir une API universelle, rappelons que le but de virtio est de limiter drastiquement le nombre de drivers dans l’OS invité, même si la quête de performances, avec des blocs rangés dans un S3, sera bien loin des latences d’un disque NVMe.
POC
Pour mettre les mains dans le moteur, et comprendre l’article, j’ai décidé d’écrire un POC en golang, oui, l’article vante Rust, et tous les outils modernes liés à kvm et virtio ne jurent que par Rust et le zero-copy.
Mais c’est un POC, réalisé par une équipe de 1, pour comprendre comment s’architecture cette abstraction de disque avec dans la boucle des téléchargements depuis un S3 et du cache distant.
Mon interrogation porte surtout sur l’utilisation d’images disques dédoublonnées, en mode bloc, alors que Containerd (Docker et K8s) ne jure que par les tar et overlayfs. Il est même possible d’indexer des tar pour faire des seek dans l’archive, pour y lire un fichier précis (comme le propose le vénérable tarindexer).
Golang permet d’avoir rapidement du code lisible, des performances décentes, et de l’asynchrone qui fonctionne, ce que ne permet pas (plus?) du python ou du nodejs.
NBD
Pour pouvoir exposer ces blocs dynamiques dans un Linux, l’abstraction sera donc NBD, comme le fait qemu-nbd pour exposer une image qcow2.
go-nbd utilise une interface
simplissime : on lit et écrit des listes de bytes à une position précise, le ReadAt
/WriteAt
de la bibliothèque io
de Golang. Le code métier va donc se contenter d’implémenter une interface
sans dépendre de la bibliothèque, il est ainsi théoriquement possible de brancher un autre protocole pour exposer des blocs.
Le code de la maquette est dans le projet stream-my-root.
git clone https://github.com/athoune/stream-my-root.git
cd stream-my-root
make submodule
Blocs
Peu convaincu par les tactiques de chargement paresseux de fichiers, Lambda décide de partir sur du bloc.
Pour chaque image OCI, on crée une image disque, en ext4, et on y désarchive les tar réclamés dans le manifest.
L’image est découpée en blocs de 512Ko appelés chunks. Les chunks vides ne sont pas conservés.
Chaque chunk est nommé à partir de son hachage, un SHA256.
Un nouveau manifest est créé, listant les chunks et leur position.
Pour avoir de la mutualisation de chunks équivalent à la mutualisation de layers des images OCI, il faut que la création d’images disque et les écritures soient déterministes, ce que ne font pas les outils ext4 classiques. L’écosystème ext4 n’est pas énorme (et sa homepage est restée dans son jus : e2fsprog), mais il existe quelques pépites.
Android, il y a longtemps, utilisait des images disques en ext4, avant de passer à F2FS, le code était disponible dans le SDK d’Android 7, puis a disparu dans les versions suivantes. Il y a eu un paquet debian, qui tentait de bien ranger un SDK bien invasif.
Heureusement, il y a eu des forks, dont le fork make_ext4fs d’OpenWRT qui l’a nettoyé de toutes dépendances exotiques, et qui fonctionne même avec autre chose que la glibc. Du beau travail de dev embarqué.
Lambda évoque l’utilisation d’un code spécifique qui linéarise les écritures, sans trop préciser le logiciel en question. fuse2fs (qui fait partie de e2fsprog) semble faire le job.
Pour rapatrier les images OCI et ses couches, j’ai déjà écrit un billet sur le distroless qui explique comment le faire avec curl
et jq
, cette fois-ci, crane
fera très bien le job : manifest2layers.sh
Pour créer une image disque, empiler le contenu des archives tar dans l’image, ce sera avec make_ext4fs
et fuse2fs
: tar2img.sh.
Ces deux scripts utilisent des logiciels exotiques ou dans des versions trop récentes, et sont donc emballés dans un conteneur Docker, accessibles via une commande make.
make docker-tool
make img NAME=gcr.io/distroless/base-debian12
Les outils pour créer une image ext4 et la découper ne requièrent pas de privilèges root, juste un accès à /dev/fuse
.
L’image a une taille fixe, et sera pleine de vide, mais bon, les systèmes de fichiers modernes le gèrent bien (tout comme GNU tar avec -S).
$ ls -slh out/*.img
42M -rw-r--r-- 1 root root 1.0G Apr 18 16:31 out/gcr.io_distroless_base-debian12.img
55M -rw-r--r-- 1 root root 1.0G Apr 21 11:50 out/gcr.io_distroless_java-base-debian12.img
57M -rw-r--r-- 1 root root 1.0G Apr 19 10:42 out/gcr.io_distroless_python3-debian12.img
24M -rw-r--r-- 1 root root 1.0G Apr 18 16:22 out/gcr.io_distroless_static-debian12.img
Les outils suivants seront en golang:
make build
Pour trimmer les zéros, puis découper en chunks, ce sera le cli en go chunk qui utilise la bibliothèque chunk.
Pour grappiller de la place, les chunks étant de taille connue (car fixe), ils sont tronqués, et n’ont pas leurs zéros en fin de fichier.
L’article dit bien que la compression avant chiffrage n’est pas recommandée, mais les chunks sont pleins de trous (des longues suites de zéro), et la compression m’évite de coder un sparse bytes
dans un premier temps.
Une “recette”, préfixé en .recipe
est écrite pour chaque image OCI, en format texte pour l’instant.
Les chunks sont entassés dans un même dossier, une partie d’entre eux seront communs entre différentes images.
./bin/chunk out/*.img
# les recettes sont dans le même dossier que l'image
ls -lh out/*.recipe
# les chunks sont dans le dossier smr
ls -lh smr
L’outil diff permet de comparer deux recettes, de compter le nombre de chunks de chacun, et surtout le nombre de chunks partagés. Les scores sont crédibles, une image dérivant d’une autre aura à peu près le bon nombre de chunks en commun. Il y a vraisemblablement des informations uniques dans le premier chunk, je n’ai pas été assez agressif sur les options de make_ext4fs
.
$ ./bin/diff out/gcr.io_distroless_python3-debian12.img.recipe out/gcr.io_distroless_base-debian12.img.recipe
A: 123 chunks, 121 unique chunks
B: 58 chunks, 56 unique chunks
B has 51 chunks in common with A, 7.9 MB
Le serveur NBD va utiliser le backend en lecture seule, seule partie du code lié à go-nbd, qui va utiliser le module blocks, la seule partie de code un peu complexe, pour gérer les zéros implicites.
Le debug de serveur NBD est ingérable, les outils Linux ne sont simplement pas faits pour ça, même s’il logue gentiment dans /var/log/kernel.log
, sur une lecture incohérente il se bloquera. Avec un Linux Alpine, ce sera encore pire, le client nbd est géré par busybox
.
La nouvelle bibliothèque standard slog
permet d’avoir des informations en mode debug, pour avoir le contexte et la partie de code qui crash. Il est étonnement simple de lancer le debugger delve
depuis VSCode avec le client NBD dans un Linux virtualisé.
Mais ce n’est pas la bonne approche, les tests fonctionnels ne doivent être faits qu’à la toute fin, une fois que l’on a un serveur dans un état présentable.
Il est étonnement facile de s’autopipoter avec des tests unitaires qui affichent un excellent ratio de couverture. Les tests unitaires doivent être complétés par du fuzzing avec des fixtures crédibles, en l’occurrence de vraies images. Une fois que le fuzzing trouve une erreur, on l’ajoute aux tests unitaires (pour la lisibilité du prochain qui lira le code), on corrige, puis, “rinse and repeat” comme on dit en Amérique.
Le fuzzing de golang est agréable à utiliser, mais il utilise beaucoup de magie, avec des arguments non nommés, de l’introspection, et cette magie apparait dans les très confuses stacks trace de vautrage.
make fuzz
Pour compléter le fuzzing, le cli debug utilise le backend, la plus haute couche du code métier, juste avant go-nbd, et va faire des itérations seek+read aléatoires sur l’image brute, et la même chose sur le backend, pour comparer les deux.
./bin/debug out/gcr.io_distroless_python3-debian12.img
Le serveur utilise une recette
./bin/server out/gcr.io_distroless_python3-debian12.img.recipe
Qemu fournit un outil de debug pour décrire un serveur nbd
$ qemu-img info nbd://localhost:10809/smr
image: nbd://localhost:10809/smr
file format: raw
virtual size: 1 GiB (1073741824 bytes)
disk size: unavailable
Child node '/file':
filename: nbd://localhost:10809/smr
protocol type: nbd
file length: 1 GiB (1073741824 bytes)
disk size: unavailable
On peut maintenant monter l’image depuis un Linux (physique ou virtuel).
# nbd ne se plaindra que dans kernel.log, découper votre tmux avec un ctrl-%
tail -f /var/log/kern.log
# le module nbd doit être chargé
sudo modprobe nbd
# nbd-client va connecter le serveur avec un /dev/nbdx
sudo nbd-client -N smr localhost 10809 /dev/nbd1
sudo mkdir /mnt/smr
# le serveur est en lecture seule
sudo mount -o ro -t ext4 /dev/nbd1 /mnt/smr
# l'image est disponible, ou peut l'utiliser
ls /mnt/smr
Conclusion
Grâce à la persévérance d’OpenWRT, il est possible de créer des images disques déterministes, avec des chunks partagés entre différentes images. NBD permet de prototyper un serveur de bloc simplement, et un Linux vanilla saura l’utiliser.
Le prototype ne dépendant pas de go-nbd, il ne devrait pas être compliqué de tester avec l’audacieuse stratégie de blocks over FUSE, car rust-vmm n’en a que faire de nbd, pas plus que Firecracker.
Le cache en LRU/K, un serveur de cache à la Memcache/Redis, les chunks dans S3, ainsi que le chiffrage ne devrait pas poser de problèmes.
Le copy on write
pour avoir un disque en lecture/écriture, le sparse bytes
pour se passer de compression, le taint read
pour connaitre les chunks utilisés lors du démarrage nécessiteront un peu plus d’attention.
Exposer les blocks via un vhost user backend devrait être un poil plus sportif, mais bon, il y a des specs et une implémentation de référence.
Je ne m’attendais pas à ce que l’article permette de faire une maquette, et qu’il utiliserait des outils secrets insuffisamment définis pour être reproduit. Mais non, avec pas mal de RTFM et d’assiduité dans la chasse aux bugs, ça passe.