REX: Images Docker Multi-CPU Architecture « faites maison »

Bien que le sujet ne soit pas des plus récents, nous nous sommes dernièrement penchés sur la question : Comment construire nos images Docker internes pour qu’elle puissent être compatibles et facilement utilisées sur plusieurs architectures CPU ?

Pourquoi ?

Cette question n’est pas complètement innocente car elle s’est imposée à nous suite à un petit problème sur le poste flambant neuf de l’un de nos développeurs :

Celui-ci a récemment dû mettre à jour son poste de travail et a donc reçu un MacBook récent pour remplacer son matériel vieillissant et agonisant sous le poids des projets et logiciels à exécuter au quotidien. La prise en main de la nouvelle machine se fait bien, le développeur réinstalle tranquillement ses outils … Bref, tout va bien dans le meilleurs des mondes.

Sauf que …

Après quelques temps d’utilisations, le développeur nous signale des problèmes pour démarrer l’instance locale d’un projet – fournie sous la forme d’une configuration docker-compose – sur lequel il doit travailler. Quelques minutes d’appel vidéo et de partage d’écran plus tard, le diagnostic est sans appel :
Nous essayons simplement de lancer des conteneurs basés sur des images faites pour une architecture x86_64 (aussi appelée amd64) sur une machine … utilisant un processeur avec une architecture basée sur ARM, le fameux processeur Apple Silicon M1 (qui utilise une architecture appelée aarch64, ARMv8 ou encore ARM64).

Pas de panique, consultons la documentation officielle Docker Desktop for Mac (version Apple Silicon).
Bien qu’encore en Beta à ce moment là, il semblerai qu’il nous reste un espoir de faire fonctionner notre stack à l’aide d’options fournies par Docker Desktop qui permettraient de lancer les containers x86_64 dans un émulateur QEMU :

Documentation Docker Desktop for Apple Silicon

On s’empresse alors pour essayer cette option magique… sans succès malheureusement, et la suite de la documentation nous confirme que cette option n’est pas si magique que ça :

Documentation Docker Desktop for Apple Silicon… la suite

Il a fallu se rendre à l’évidence : il nous faut des images/conteneurs pour cette architecture, sinon nos belles configurations de stack de dev basées sur docker-compose ne serviront à rien pour les collègues qui se retrouveront équipés de poste avec Apple Silicon M1.

Mais aussi…

Si on jette un œil à tout ce qu’il se passe autour des conteneurs et leur écosystème, on se rend compte que les architectures autre que la classique x86_64 sont bien présentes et déployées par différentes populations d’utilisateurs non-négligeables.

Par exemple, toujours dans l’écosystème ARM, on pourra noter :

  • Les différents Single-Board Computers tels que les populaires Raspberry Pi ou NVIDIA Jetson Nano, aussi bien utilisé pour des projets tels qu’un cloud personnel à base de cluster Kubernetes (plus ou moins modeste) que pour des projets plus professionnels ou industriels de type IoT, Edge Computing ou informatique embarquée.
  • Le déploiement progressif de machines ARM dans certains Clouds publics, comme par exemple les instances Amazon EC2 utilisant des processeurs AWS Graviton ou encore l’utilisation des processeurs Ampere Altra pour des charges de travail cloud par des sociétés telles que Microsoft, Scaleway, Cloudflare, Tencent Cloud ou encore Oracle.

Et du côté des « architectures qui montent », le candidat le plus remarquable au moment d’écrire ces lignes est sans doute RISC-V (https://riscv.org/) qui propose d’appliquer la philosophie du libre et de l’Open-Source à l’architecture processeur, en ne dépendant plus des architectures et jeux d’instructions habituels (x86, ARM, …) mais en utilisant un standard ouvert et sans les contraintes de licence que peuvent présenter certains standards actuels. Parmi les utilisateur de cette nouvelle architecture, on pourra citer par exemple Alibaba Cloud, qui a déjà réalisé un benchmark de son implémentation de RISC-V vs. ARM Cortex.

Comment ?

La version simple (simpliste ?) serait de laisser le développeur builder « à la main » les images dont il a besoin, directement sur son poste.
Bien que ce semblant de solution semble pratique de prime abord, les inconvénients qu’elle apporte risquent de vite devenir compliqués à gérer au quotidien, voir bloquants :

  • Chaque développeur qui sera à l’avenir équipé d’une machine de ce type (nouveau développeur ou changement de poste) devra passer par une session de build des images dont il aura besoin avant de pouvoir travailler sur un projet,
  • Risque de se retrouver avec plusieurs variantes «personnalisées » d’une seule et même image, distribuées sur les postes des collaborateurs, ce qui entraînerai le retour du fameux « chez moi ça marche !™ »,
  • Absence d’une image de référence, construite de façon contrôlée et normalisée, et qui pourrait être utilisée dans le point suivant,
  • Pas d’image générée automatiquement pour valider sa construction et/ou son déploiement sans erreur sur un environnement cible

Tout ces points influenceraient sans aucun doute la qualité de nos livrables de façon négative. Se contenter de cette demi-solution n’est donc pas envisageable et ne ferait que repousser l’inévitable : il faut que nos pipelines de génération d’images Docker sous Gitlab-CI soient adaptés pour fournir des résultats compatibles avec les deux architectures (amd64 & aarch64).
Ces images pourront alors être publiées sur notre registry interne pour pouvoir être récupérées et déployées à la demande sur les postes des développeurs ou autre environnement cible.

Première tentative : déployer un runner dans une machine virtuelle

N’ayant pas d’autre machine Apple Silicon sous la main mais un peu de place disponible sur notre hyperviseur, une VM Debian 10 ARM a été créée pour y installer le runner GitLab.

Et… c’est lent…

TRÈS lent…

TROP lent.

La couche de virtualisation/émulation pour permettre l’exécution d’un système pour ARM ralenti significativement toutes les opérations de la machine virtuelle sur cette plateforme partagée.
L’installation du système d’exploitation a pris plusieurs heures. Et pour le build d’image Docker, les performances ne sont pas plus au rendez-vous :

La pipeline de référence (utilisant les runners existant sur x86_64) s’exécute et fournit un résultat en ~1m 50s.

Capture d'écran indiquant un temps de build de 1'48"

La pipeline exécutée par le runner sur la VM ARM/aarch64 de son côté s’est terminée… après plus de 48 minutes (!)

Et tout ça juste pour générer un binaire tout bête ne nécessitant pas énormément de dépendances ou de téléchargements.
Alors si on extrapole un peu pour estimer le temps de build d’un projet basé sur Liferay par exemple, on se rend vite compte qu’on sera loin d’un temps de build raisonnable et par extension d’une solution utilisable.

Retour à la case départ; solution suivante.

Deuxième tentative : Émulation QEMU, binfmt_misc et docker buildx

Si on creuse un peu le blog et la documentation officielle Docker, on y découvre la commande docker buildx (encore considérée expérimentale lors de nos tests) qui permet d’utiliser les capacités binfmt_misc du noyau Linux, combinées à l’émulateur QEMU pour builder des images pour des architectures autres que celle de la machine exécutant Docker.

L’avantage, c’est qu’à l’aide d’une seule commande, on déclenche en parallèle la construction des images pour toutes les plateformes que l’on souhaite cibler (ici amd64 et aarch64).

Une fois la configuration du job modifiée pour utiliser buildx en lieu et place de l’habituel docker build, il n’y a plus qu’à relancer la pipeline.

C’est beaucoup mieux ! Et ces temps peuvent encore s’améliorer sur les prochains builds grâce à la fonctionnalité de build cache proposée automatiquement par Buildx.
Mais un temps de build de 19 minutes, même s’il peut être réduit en partie à l’aide du cache, reste trop long comparé aux ~1min 50s du pipeline de référence.

Autre inconvénient : les logs des builds pour chaque architecture sont mélangés dans le même flux de sortie, ce qui peut rendre le débogage compliqué lorsqu’une seule des deux architectures rencontre un problème.
Par exemple lors de nos tests, le build ciblant aarch64 s’est interrompu à cause d’une erreur lors de l’installation des dépendances, mais la tache buildx en elle même a continué sans mettre la pipeline en erreur, donnant une image multi-architecture avec une variante aarch64 inutilisable.

Finalement, ce qui semblait être un avantage de prime abord se transforme en inconvénient, car il est difficile de suivre l’évolution des temps de builds pour chaque architecture étant donné que tout est regroupé dans un seul job de pipeline générant un seul long flux de logs.

Troisième tentative : Runner ARM & docker manifest

Si l’on décortique ce que fait docker buildx, on apprend que sous le capot c’est la commande docker manifest qui nous permet d’obtenir le résultat souhaité : avoir une seule référence image:tag générique, mais qui pointe automatiquement vers la variante correspondant à l’architecture cible du client Docker.

En suivant les différentes documentations, tutoriels et guides sur le sujet, la nouvelle pipeline Gitlab-CI était prête :

Ce qui nous donne une pipeline sous cette forme :

Il ne nous manquait plus qu’un runner natif ARM64.

Pour cela, j’ai dégainé mon RaspberryPi 3B+ que j’avais sous la main. Celui-ci dispose d’un processeur ARM Cortex-A53 qui implémente le jeu d’instruction ARMv8-A 64 bits (une parmi les fameuses variantes sous le nom aarch64). Il est également équipé pour la partie stockage d’un PiDrive (= un disque dur USB spécifiquement conçu pour une utilisation par le RaspberryPi), ce qui devrait nous apporter des performances et un espace de stockage plus confortables que la classique carte micro-SD utilisé sur ce genre de Single-Board Computer.

Une installation de RaspberryPi OS 64bit et du Gitlab Runner plus tard, et nous voilà prêt pour lancer de nouveau notre pipeline :

Voilà qui est beaucoup mieux !

Même si l’on reste malgré tout à plus de 2× le temps de build par rapport l’architecture de référence x86_64, il faut mettre en perspective le fait que nous ne sommes pas sur la dernière mouture du RaspberryPi (la version 4 apporte son lot d’améliorations non-négligeables), connecté à un WiFi plutôt que directement en ethernet sur le réseau interne et en travaillant à partir d’un disque dur (pas un SSD) connecté en USB2.0 (donc limité en débit).

Mais plutôt que de s’emballer trop vite, vérifions les performances avec une image « de la vraie vie ».
Voici les temps de builds pour un pipeline produisant une image Liferay :

Premier build Liferay sur Raspberry Pi 3B+

Aïe… Le temps de build explose ! Si l’on compare avec l’exécution d’un build « classique », on a multiplié la durée par ~7…
Sauf qu’il y a un détail à prendre en compte pour appréhender ce résultat :

Le runner habituel ne part pas « de rien », il dispose déjà dans son cache ou dans son registry local des images de base sur lesquels est construite l’image Liferay. Ce nouveau runner, lui, part d’une page blanche. Il faut donc lui laisser le temps en début de build de télécharger (par l’intermédiaire du docker pull) ces différentes images de base.

En regardant les build logs , on se rends compte qu’une part conséquente du temps consommé par la pipeline est passé à télécharger ces fameuses images (sûrement à cause d’une connectivité WiFi peu performante). Pour s’en assurer, lançons un second pipeline.

Second build Liferay sur Raspberry Pi 3B+

Et cette fois le timing est beaucoup plus raisonnable, et pourrait peut-être même diminuer encore en se connectant directement en Ethernet plutôt qu’en utilisant la connexion WiFi, car ce build télécharge des dépendances à l’aide de Maven.

La même expérience sur un RaspberryPi 4 connecté directement au réseau interne dans notre baie technique, utilisant un stockage SSD connecté en USB3.0 (voir, pour les plus aventureux, un RaspberryPi 4 Compute Module couplé à un SSD NVMe) pourrait peut-être même atteindre un temps de build équivalent voir inférieur à celui sur amd64.

Une alternative un peu plus coûteuse – mais qui permettrait d’accueillir également des builds pour d’autres plateformes tels que la construction d’applications iOS – serait l’utilisation d’un Mac Mini M1, pour être au plus près de la configuration des postes de développeur avec un processeur Apple Silicon M1.

Pour finir

Voilà où nous en sommes aujourd’hui : Nous avons 2 solutions en lice pour intégrer les builds multi-architecture à nos pipelines, développements et déploiement : l’une plus performante mais nécessitant l’ajout de nouveau matériel, et l’autre utilisable en l’état si le compromis des temps de build plus important est acceptable d’un point de vue de l’activité de production.
La discussion est toujours ouverte pour faire pencher la balance sur l’une ou l’autre de ces deux alternatives, et les premières images multi-architecture buildées sur ces pipelines ont encore besoin d’être testées plus en profondeur dans des conditions de travail du quotidien.

À noter

Si vous vous lancez aussi dans la mise en place de pipeline et builds de ce type, pensez à vérifier le contenu de vos Dockerfile pour y détecter d’éventuelles instructions qui ne seraient pas utilisables d’une architecture à l’autre.
Par exemple, si vous basez vos images sur la distribution Debian et que vous ajoutez des dépôts apt supplémentaires (dans /etc/apt/sources.list), prenez garde à ne pas « coder en dur » l’architecture à utiliser pour le dépôt.
Pour cela, vous pouvez exploiter les build arguments proposé par docker build et/ou les arguments de plateforme fournis automatiquement par BuildKit (par l’intermédiaire de buildx).

Voici un extrait d’un Dockerfile pour une image servant pour des builds d’images Quarkus :

En utilisant BuildKit comme builder, vous pouvez utiliser :

Autre point qui peut être intéressant à connaître : buildx est capable de prendre en charge des nœuds docker distants (remote node), ce qui permet d’avoir plusieurs nœuds docker (= une machine avec son API Docker exposée et accessible) pouvant prendre en charge les tâches de build. Et dans le cas d’un build multi-architecture, buildx se chargera de distribuer la construction des variantes de l’image au bon nœud en fonction de son architecture.

Et vous ? vous en êtes où avec vos images et conteneurs multi-architecture ?

N’hésitez pas à nous faire part de vos remarques et questions par twitter (@SedonaArchitect) ou par email (architecture@sedona.fr)

You may also like...