Le cache qui ne cachait rien

Dec 9, 2024 min read

Chez la Fondation Tezos, on compilait de l’OCaml. Beaucoup. Des milliers de builds par jour, sur des machines de CI qui naissaient et mouraient dans la journée. Et elles commençaient toutes par la même chose, télécharger leurs dépendances.

Ça posait deux problèmes. Le premier est évident. Chaque build retélécharge les mêmes paquets, depuis les quatre coins d’internet, et on recommence à zéro à chaque fois. C’est lent, et surtout, dans le cloud, ça se facture. Des milliers de builds par jour qui vont rechercher encore et encore les mêmes archives, ce n’est pas une paille sur la facture, c’est une ligne qui grossit avec la CI sans rien apporter de plus. Ce qu’on voulait, c’était un cache local, un seul, partagé par tous les jobs de CI, où chaque paquet n’est téléchargé qu’une fois.

Le deuxième problème est moins visible, mais c’est le vrai. On voulait garder la main sur ce que la CI a le droit d’aller chercher sur internet. Une machine de build qui ouvre des connexions vers n’importe quel serveur, c’est quelque chose qu’on ne sait pas décrire, et encore moins surveiller.

La réponse à ces deux problèmes tient en un mot, un cache. J’ai commencé par là. Et je me suis aperçu que dans le cas d’opam, ça ne cache rien du tout.

Ce qu’opam télécharge vraiment

Il faut regarder comment marche le gestionnaire de paquets d’OCaml, parce que tout est là.

Quand vous installez un paquet, opam commence par récupérer un fichier tar.gz construit à partir du dépôt officiel opam-repository. Ce fichier ne contient aucun code. Il contient juste des descriptions, un petit fichier opam par version de chaque paquet, avec les dépendances, les commandes de build, et surtout ça :

url {
  src: "https://github.com/quelquun/son-paquet/archive/v1.2.3.tar.gz"
  checksum: "sha256=a3f1..."
}

Une URL. Vers github, vers gitlab, vers le serveur perso d’un chercheur, vers un site d’université. Opam lit cette URL, va chercher l’archive là où elle se trouve, vérifie la somme de contrôle, et compile.

Donc le dépôt de paquets n’héberge pas les paquets. C’est un annuaire. Une liste de redirections vers plusieurs milliers de serveurs différents, tenus par autant de gens qui n’ont rien à voir les uns avec les autres.

Pourquoi le cache ne cache rien ?

Vous mettez un proxy cache devant opam.ocaml.org. Qu’est-ce qui se passe ?

Il met en cache l’annuaire, une fois. Ce fichier fait quelques dizaines de mégaoctets, et c’est bien le seul qu’on lui redemande.

Ensuite chaque machine de CI lit cet annuaire, y trouve des milliers d’URLs qui pointent ailleurs, et va télécharger ses paquets aux mêmes endroits qu’avant. Le cache n’est pas sur le chemin. Il ne voit rien passer.

Et ça n’a rien de spécifique à OCaml. Un cache ne peut mettre en cache que ce qui passe par lui. Quand l’index d’un écosystème est une liste d’URLs qui pointent vers des tiers, il n’y a rien à mettre en cache. On peut le mirrorer, mais c’est un autre travail.

Il y a un deuxième problème, plus embêtant. Chacun de ces milliers de builds dépendait de plusieurs centaines de serveurs qui ne nous devaient rien. Le jour où le serveur perso qui héberge une dépendance tombe, la CI tombe avec. On appelait ça une chaîne de build. En vrai c’était une chaîne de confiance, et on n’avait jamais fait la liste de ce qu’il y avait dedans.

La solution : réécrire le chemin

Puisqu’il faut être sur le chemin, il faut réécrire le chemin.

Le programme que j’ai écrit, un service en Go que j’ai appelé opamELA, fait trois choses.

1. Il clone le dépôt officiel. Pas le tar.gz, le dépôt git complet, avec ses dizaines de milliers de fichiers opam.

2. Il réécrit chaque fichier. Pour chaque paquet, il lit l’URL d’origine, la range dans une base SQLite avec le nom du paquet et le nom du fichier, et il remplace l’URL dans le fichier opam par une URL vers lui-même :

url {
  src: "https://mon-miroir.interne/download/son-paquet/v1.2.3.tar.gz"
  checksum: "sha256=a3f1..."
}

3. Il sert le tout comme un dépôt opam normal. Pas d’API maison, pas de plugin, pas de variable d’environnement bizarre. Un dépôt opam au format standard, servi en HTTP. Côté client, il n’y a qu’une commande à taper, opam repository set-url. Opam ne sait pas qu’il parle à un miroir, et c’est fait exprès. Le jour où le service s’arrête, on repointe vers l’officiel et personne ne s’en rend compte.

La partie téléchargement est volontairement bête. Quand on demande une archive, le service regarde si elle est sur son disque. Si elle y est, il la sert. Sinon, il retrouve l’URL d’origine dans SQLite, va chercher le fichier une fois, l’écrit sur le disque, et renvoie le client vers le fichier statique. La machine suivante qui demande le même paquet, trente secondes plus tard, tape sur un fichier local.

Tout est dans le point 2. Ce n’est pas un proxy. C’est un annuaire réécrit, et c’est lui qui rend le cache possible.

La ligne que je n’ai pas touchée

Regardez ce qui ne change pas dans la réécriture. La somme de contrôle.

C’est le seul endroit du système sur lequel il faut s’arrêter un peu. Opam compare le hash de ce qu’il télécharge avec celui qui est écrit dans l’annuaire. Comme le miroir ne touche qu’à l’URL, une archive abîmée en route, un disque qui pourrit, un téléchargement coupé en deux, tout ça se voit tout de suite côté client. Le miroir n’a pas besoin d’être fiable pour que les builds soient corrects.

Il faut quand même être honnête jusqu’au bout. Le miroir sert aussi l’annuaire, donc les sommes de contrôle. Techniquement, rien ne l’empêche de servir un paquet modifié avec le hash qui va avec. La vérification protège contre l’accident, pas contre le miroir. La confiance qu’on mettait dans mille serveurs, on la met maintenant dans une seule machine, la nôtre. C’est un bon échange, un point qu’on administre vaut mieux que mille qu’on subit, mais c’est un échange, et autant le savoir.

Ce que ça a coûté

Le service a tourné, et il rendait service. Ça ne veut pas dire qu’il était bien fait.

La boucle de rafraîchissement n’a jamais été écrite. L’annuaire est construit au démarrage, une fois. Un nouveau paquet publié en amont n’existe pas pour le miroir tant qu’on ne l’a pas redémarré. C’était marqué comme un TODO dans la doc interne, et c’est resté là. La vérité, c’est qu’un redémarrage réglait le problème, alors personne n’a jamais eu assez mal pour le corriger.

L’indexation de départ était brutale. Pour chaque version de chaque paquet, le programme lance un opam show en sous-processus pour récupérer trois champs. Des dizaines de milliers de sous-processus, lancés en goroutines, qui écrivent tous dans la même SQLite sans que personne n’attende personne. Ça marchait parce que c’est fait une fois, sur une machine dédiée, et qu’on n’était pas à dix minutes près. Si je le réécrivais aujourd’hui, je lirais les fichiers opam directement et j’insérerais par paquets dans une transaction.

Il y a aussi une subtilité propre à OCaml. Il existe un dépôt de surcharges, dune-universe/opam-overlays, qui redéfinit certains paquets pour qu’ils se construisent avec dune. C’est nécessaire dès qu’on utilise opam-monorepo. On l’a fusionné avec le dépôt officiel dans le miroir. Ce genre de chose ne coûte rien quand on tient soi-même l’annuaire, et c’est impossible avec un simple cache HTTP.

Ce qu’il faut en retenir

Le programme en lui-même n’a rien d’extraordinaire. Quelques centaines de lignes de Go, une SQLite, un serveur de fichiers statiques.

Ce qui compte, c’est le raisonnement, et il vaut bien au-delà d’OCaml. Avant de mettre un cache devant un gestionnaire de paquets, posez-vous une question. Est-ce que l’index et le contenu sont au même endroit ?

Si oui, et c’est le cas de npm, de PyPI, des images de conteneurs, un proxy cache classique marche, parce que tout passe vraiment par lui. Si non, et c’est le cas d’opam, mais aussi d’une partie du monde Go d’avant les proxies de modules, de Homebrew, ou des paquets sources Debian, il n’y a pas de cache possible. Il faut réécrire l’index, ou laisser tomber.

Et il y a un bénéfice auquel je n’avais pas pensé en commençant. Une fois l’annuaire réécrit, la liste complète de ce qui entre dans nos builds tenait dans une table SQLite, avec pour chaque ligne l’URL exacte d’où ça venait. Ce n’était pas le but, je voulais juste que ça aille plus vite. Mais c’est sans doute ce qui avait le plus de valeur.

Le code

Le programme que je viens de décrire appartient à mon ancien employeur, je ne peux pas le publier. L’idée, par contre, elle est à moi, et elle méritait mieux que sa première version.

Je l’ai donc réécrite de zéro, proprement, sous licence Apache 2.0. Les fichiers opam sont lus directement au lieu de passer par des sous-processus, l’index se rafraîchit tout seul, les archives sont vérifiées contre la somme de contrôle du paquet avant d’être mises en cache, et les surcharges de dune-universe peuvent être fusionnées avec le dépôt officiel. Les trois défauts de la section d’avant, en somme.

Édit, longtemps après ce billet : j’ai fini par mettre ce code en ligne, sur GitHub, sous le nom opamela. Le dépôt est donc daté bien après cet article, mais c’est le même programme, celui dont je parle ici. Je l’ai publié tard, c’est tout.

En pratique, vous le faites tourner à l’intérieur de votre réseau de CI, à côté des runners. Vous pointez opam dessus avec une seule commande, opam repository set-url. À partir de là, chaque paquet ne traverse l’internet public qu’une fois : le premier build qui en a besoin le récupère, tous les suivants le lisent en local, à la vitesse du réseau. Et le jour où vous voulez arrêter, vous repointez opam vers l’officiel, il n’y a rien d’autre à défaire.

Le miroir complet des 22 751 paquets se génère en deux secondes. Si vous compilez de l’OCaml en quantité, servez-vous.

-|