QEMU : utiliser une architecture incompatible avec l'hôte

Attention:

Cet article est très spécifique, vous n’avez probablement pas besoin de le lire si vous voulez découvrir Docker!

Saviez-vous que l’utilisation conjointe de Docker et QEMU permet d’utiliser par exemple un conteneur ARMv7 sur un hôte x86-AMD64 de manière (presque) totalement transparente ? Non vous ne rêvez pas, c’est même très simple à mettre en place !

Question:

Architecture x86, ARM ? Quèsaco ?

Ces noms d’architecture représentent la manière dont sont conçus et fonctionnent les microprocesseurs. La famille x86 est la plus répandue dans le monde des PC et des serveurs, il s’agit de l’ensemble des processeurs conçus par Intel et AMD, privilégiant la puissance de calcul. La famille ARM, développée par l’entreprise éponyme, privilégie l’efficacité énergétique ; elle équipe votre smartphone, les Raspberry Pi, et la plupart des objets connectés. Il existe plein d’autres architectures, comme MIPS, AVR, Risc-V… Un programme compilé pour une famille d’architecture ne fonctionnera directement sur aucune autre famille.

C’est là qu’intervient QEMU : QEMU est un logiciel libre et open-source qui permet d’émuler le comportement de n’importe quelle architecture de microprocesseur, pour un programme conçu pour un système Linux ou BSD.

Question:

Mais pourquoi aurait-on besoin de mélanger ces architectures ?

Il y a plusieurs cas d’utilisation où il est utile d’émuler un microprocesseur différent de celui de la machine hôte, notamment durant des phases de développement ou de test d’un programme. Imaginez que vous développez un gros logiciel compilé pour des Raspberry Pi, architecture ARMv7, mais que votre poste de travail est équipé d’un microprocesseur x86-AMD64. Il y a alors 4 manières de compiler ce programme :

  • Compiler depuis une Raspberry Pi : cette solution fonctionne pour des petits projets, mais montre rapidement ses limites en matières de ressources matérielles disponibles (surtout la RAM), et est en général la plus lente des 4 solutions ;
  • Compiler depuis le poste de travail, avec un compilateur adapté : c’est la solution idéale sur le papier, mais plus le projet est complexe, possède de dépendances, plus cette solution est compliquée à mettre en place ;
  • Compiler depuis un serveur ARM puissant dans le «cloud» : cette solution est simple à mettre en place, mais ce n’est pas très courant d’avoir un tel serveur sous la main ;
  • Compiler depuis un OS Linux ARM émulé sur le poste de travail : cette solution permet la même flexibilité qu’un serveur ARM, c’est celle qui sera détaillée ci-dessous.

Question:

Mais alors, pourquoi cette dernière solution n’est-elle pas très courante ?

La possibilité d’exécuter un jeu d’instructions pour un microprocesseur différent vient avec une contrepartie : la performance qui devient mauvaise par rapport à la puissance de calcul du microprocesseur hôte. En effet, il est rarement possible de directement traduire une instruction d’une architecture A vers une instruction de l’architecture B, et c’est encore plus vrai entre ARM (dit RISC, jeu d’instructions restreint au minimum, moins d’une soixantaine en général) et x86 (dit CISC, jeu de plusieurs centaines d’instructions complexes). Le résultat c’est que pour exécuter une seule instruction de l’architecture A, il faut plusieurs instructions de l’architecture B (sélectionner la bonne suite d’instruction à exécuter, modifier la représentation des données si besoin, etc), pour un «coût» en cycle de calcul très supérieur ; ainsi, l’émulation a un impact très significatif sur la vitesse d’exécution.

Note:

À titre d’exemple, voici des performances mesurée pour la compilation de l’ IA des robots d’UTCoupe en 2019, utilisant un très gros outil C++ pour la robotique :

  1. ~40 secondes de compilation sur une machine possédant un Intel i7 haute-performance, avec 8 threads (files d’exécution parallèle);
  2. ~15 minutes depuis une Raspberry Pi 3, avec 1 thread (il n’y a pas assez de RAM pour utiliser plus de thread) ;
  3. ~5 minutes depuis le même PC avec le CPU Intel, avec 8 threads et QEMU+Docker.

Afin de pouvoir exécuter les programmes de l’architecture différente dans un conteneur, on peut enregistrer temporairement les interpréteurs sur la machine hôte pour les mettre à disposition. Cela est réalisé par la commande suivante :

 $ docker run --rm --priviledged multiarch/qemu-user-static --reset -p yes
Setting /usr/bin/qemu-alpha-static as binfmt interpreter for alpha
Setting /usr/bin/qemu-arm-static as binfmt interpreter for arm
[...]
Setting /usr/bin/qemu-microblazeel-static as binfmt interpreter for microblazeel
Setting /usr/bin/qemu-or1k-static as binfmt interpreter for or1k

Veuillez noter la présence de l’option –privileged qui permet au conteneur d’interagir avec le système hôte. L’option –reset demande au script de remplacer les exécutables s’ils existent déjà, et -p yes les persiste lorsque le conteneur se ferme.

Attention:

Cette commande est à exécuter à chaque fois que le démon docker est redémarré (typiquement quand la machine hôte redémarre).

Le script a ajouté les interpréteurs dans le dossier virtuel /proc/sys/fs/binfmt_misc/ de la machine hôte. On peut vérifier leur activation :

$ cat /proc/sys/fs/binfmt_misc/qemu-arm                                            
enabled
interpreter /usr/bin/qemu-arm-static
flags: F
offset 0
magic 7f454c4601010100000000000000000002002800
mask ffffffffffffff00fffffffffffffffffeffffff

Cette méthode a l’avantage de ne pas nécessiter d’installer des paquets supplémentaires sur la machine hôte. Mais tout ce qui suit est tout-à-fait valable si vous avez installé qemu en global (apt install qemu-user-static).

Comme indiqué dans l’introduction, ici rien ne change, on peut utiliser docker normalement. Enfin presque, car docker sélectionnera toujours l’architecture hôte pour une image disponible avec plusieurs architecture. Pour sélectionner l’architecture voulue, il faut utiliser l’option –platform=linux/arm64. À ce jour (docker v19.03.13), cette option n’est disponible que en mode experimental.

Voici un exemple d’exécution :

$ uname -a
Linux gaetan-PC 5.8.0-38-generic #43-Ubuntu SMP Tue Jan 12 12:42:13 UTC 2021 x86_64 x86_64 x86_64 GNU/Linux
$ docker run --rm -it arm32v7/alpine uname -a
Linux fcee9f02cc9a 5.8.0-38-generic #43-Ubuntu SMP Tue Jan 12 12:42:13 UTC 2021 armv7l Linux
$ docker run --rm -it arm64v8/alpine ls         
bin    etc    lib    mnt    proc   run    srv    tmp    var
dev    home   media  opt    root   sbin   sys    usr

Note:

Si vous obtenez l’erreur

standard_init_linux.go:211: exec user process caused "exec format error"

c’est que vous avez oublié de configurer qemu !

À partir de maintenant, il est possible d’utiliser une image docker contenant un compilateur d’une autre architecture pour effectuer de la cross-compilation. Mais on peut encore aller plus loin : créer une image pour une autre architecture !

De même que pour l’exécution, en cas d’ambiguïté sur l’architecture d’une image, Docker sélectionnera toujours celle de l’architecture hôte. Ainsi, il n’est pas possible de commencer une image avec

FROM scratch

Cependant, on peut préciser une image exclusivement disponible pour l’architecture souhaitée, puis continuer à la construire comme on construirait une image normale. Il est aussi autorisé de mélanger les architectures — à vos risques et périls — pour une image multi-stage. Par exemple,

Dockerfile
FROM alpine as first_stage
 
RUN uname -a > /uname1.txt
 
FROM arm64v8/busybox
 
COPY --from=first_stage /uname1.txt /uname1.txt
 
CMD echo "First stage: " && cat /uname1.txt && echo "Last stage: " && uname -a

on obtient alors par exemple ces résultats :

$ docker build -t test_docker .  
Sending build context to Docker daemon  2.048kB
Step 1/5 : FROM alpine as first_stage
 ---> 7731472c3f2a
Step 2/5 : RUN uname -a > /uname1.txt
 ---> Using cache
 ---> 7d147fe81388
Step 3/5 : FROM arm64v8/busybox
 ---> bffe63f0059e
Step 4/5 : COPY --from=first_stage /uname1.txt /uname1.txt
 ---> Using cache
 ---> 0b7ea3dd5953
Step 5/5 : CMD echo "First stage: " && cat /uname1.txt && echo "Last stage: " && uname -a
 ---> Running in 33ffd9407823
Removing intermediate container 33ffd9407823
 ---> b76215700cbb
Successfully built b76215700cbb
Successfully tagged test_docker:latest
 
$ docker run --rm -it test_docker                                       
First stage: 
Linux cd2527490809 5.8.0-38-generic #43-Ubuntu SMP Tue Jan 12 12:42:13 UTC 2021 x86_64 Linux
Last stage: 
Linux 211b99ac36e4 5.8.0-38-generic #43-Ubuntu SMP Tue Jan 12 12:42:13 UTC 2021 aarch64 GNU/Linux

De même, il est possible d’utiliser l’option –platform=linux/arm64 dans l’instruction FROM si docker est en mode experimental.

Important:

Suivant les clients dockers, il est possible que l’architecture indiquée dans l’image ne corresponde pas à celle réelle ; dans ce cas vous pourriez par mégarde écraser une image de l’architecture hôte au moment de pousser l’image sur un serveur multi-arch !

Pour connaître l’architecture que docker a associé avec une image, on peut utiliser la commande suivante :

$ docker inspect test_docker | grep Architecture
        "Architecture": "arm64",

Note:

En théorie, rien ne vous interdit de copier un binaire d’une architecture A dans une image d’une architecture B. Cependant, vous ne pourrez pas l’exécuter, même si qemu est configuré sur la machine hôte.

  • technique/docker/general/quemu_cross_running.txt
  • de qduchemi