Valentin Haudiquet


Compilation de binaires pour plusieurs distributions Linux

Position du problème

Voilà un code qui devrait s'exécuter partout (n'importe quel Linux, UNIX, même Windows (avec un '\r\n')) :

#include <stdio.h>

int main()
{
	printf("Hello world !\n");
	return 0;
}

Une fois compilé vers un binaire elf sur une distribution Linux, par exemple avec gcc hello_world.c -o hello_world, on pourrait s'attendre à ce que l'exécution de hello_world ne pose pas de problème quel que soit le Linux en question.

Ce n'est pourtant pas le cas ; compilé sur un Ubuntu 22.04 ou un Arch Linux, puis ramené sur Ubuntu 20.04, on obtient :

root@e12cb53a6288:/# ./hello_world
./hello_world: /lib/x86_64-linux-gnu/libc.so.6: version 'GLIBC_2.34' not found (required by ./hello_world)

En effet, on peut vérifier la version de la glibc sur la machine Ubuntu 20.04 :

root@e12cb53a6288:/# /lib/x86_64-linux-gnu/libc.so.6
GNU C Library (Ubuntu GLIBC 2.31-0ubuntu9.9) stable release version 2.31.
Copyright (C) 2020 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.
There is NO warranty; not even for MERCHANTABILITY or FITNESS FOR A
PARTICULAR PURPOSE.
Compiled by GNU CC version 9.4.0.
libc ABIs: UNIQUE IFUNC ABSOLUTE
For bug reporting instructions, please see:
<https://bugs.launchpad.net/ubuntu/+source/glibc/+bugs>.

On observe bien que la glibc 2.34 n'est pas installée, et elle semble 'nécessaire' pour exécuter notre ./hello_world. Pourtant, printf n'a pas été ajouté dans la version 2.34 de la bibliothèque, ni même sensiblement modifié entre les 2 versions...

Explication, première solution naïve

En fait, la glibc fonctionne par compatibilité ascendante : un exécutable linké avec une glibc dynamique 2.34 fonctionnera sur un système avec n'importe quelle glibc 2.34+.

Une solution pour faire fonctionner notre 'hello world' serait donc de compiler/linker l'exécutable sur la plus vieille distribution que nous souhaitons supporter. Ainsi, 'hello_world' s'exécuterait "partout" et le binaire serait "universel".

Cependant, ce n'est pas si simple. En effet, même si le binaire s'exécute effectivement partout, si certains symboles dynamiques ont été changés, et que la nouvelle version est incompatible avec les versions précédentes, alors notre binaire utilisera tout le temps la vieille version du symbole, moins optimisée et/ou moins sûre en général.

C'est par exemple le cas de memcpy dans la glibc :

[valentin@xps ~]$ objdump -T /lib/libc.so.6 | grep -w memcpy
00000000000a0d30 g    DF .text	000000000000002c (GLIBC_2.2.5) memcpy
0000000000099c70 g   iD  .text	0000000000000119  GLIBC_2.14  memcpy

Depuis la version >2.14, memcpy a été optimisé au prix d'une incompatibilité si les régions 'src/dest' se chevauchent (overlap). Si nous compilons notre binaire avec une glibc 2.14 pour le rendre "universel", il utilisera tout le temps, sur tous les systèmes, la version 2.14 du symbole memcpy, moins optimisée.

Ce n'est pas un exemple isolé, et d'autres symboles altérés pour des raisons de sécurité peuvent causer des problèmes plus graves que des performances altérées.

De plus, la glibc peut ne pas être la libc choisie par une des distributions que vous visez ; par exemple, Alpine Linux est construite sur la libc musl.

Enfin, la libc est rarement la seule dépendance de votre programme ; l'utilisation de différentes bibliothèques dynamiques peut poser de nombreux problèmes. Par exemple, à l'heure où j'écris ces lignes, libzip est en version 4 sur Ubuntu 22.04, et 5 sur Arch Linux. On obtient donc ce genre d'erreurs :

[valentin@xps dockertest]$ ./svjava 
./svjava: error while loading shared libraries: libzip.so.4: cannot open shared object file: No such file or directory

La solution est alors de linker ces bibliothèques en statique (i.e. de copier tout leur code dans votre binaire), ce qui crée des exécutables énormes, et donc les symboles ne se mettent pas à jour si une nouvelle version de la bibliothèque (plus optimisée, plus sûre) sort.

Sans parler des bibliothèques comme la SDL, OpenAL, ... proches du système qui ne peuvent donc pas être compilées en statique et nécessitent des workarrounds particuliers ; par exemple, la mise en place d'un wrapper à votre exécutable, qui appelera dlsym / dlvsym le linker dynamique pour déterminer la version locale de la bibliothèque, puis la linker correctement dynamiquement avec votre exécutable réel.

Pour résumer, cette solution pour produire des binaires universels ressemble à un hack, semble difficile à mettre en place et surtout, produit des binaires de mauvaise qualité.

Holy Build Box et équivalents

La solution évoquée ci-dessus est mise en place par le système Holy Build Box. Ils construisent un conteneur docker avec une vieille distribution, donc une vieille glibc, et vous permettent de faire la build dedans.

Ils ont construit un environnement altéré avec des compilateurs/toolchains plus récents, en gardant une vieille version de libc.

Ils compilent également par défaut toutes les dépendances en statique, en proposant des workarrounds pour les bibliothèques nécessitant un link dynamique.

Cette solution est plus que correcte pour distribuer des binaires de test, qui doivent fonctionner pour une démonstration, etc.

Cependant, pour les raisons évoquées plus haut, je considère qu'il est totalement impropore de distribuer ce genre de binaires à un utilisateur final.

Il faudrait plutôt obtenir un système qui, au lieu de générer un unique binaire, permet de générer une trentaine de binaires, optimisés pour chaque distribution ; un téléchargement détectant la distribution de l'utilisateur peut ensuite être proposé, ou alors les binaires peuvent directement être empaquetés pour distributions via .deb ou gestionnaires de paquets.

Comment obtenir ce genre de système ?

Comment <logiciel connu> font-ils ?

La réponse est simple : soit ils distribuent uniquement le code source, soit il distribuent des binaires génériques comme décrit au dessus (ex. Binaires génériques de Firefox 109 en français) que personne n'utilise.

Les vrais paquets utilisés (ex. sudo apt install firefox / sudo pacman -S firefox) sont compilés par les mainteneurs de votre distribution (Ubuntu, Arch Linux), à la main, en ayant tout bien configuré comme il faut.

On peut le voir en comparant les versions de firefox (à droite générique, à gauche paquet arch) : firefox compare

Cette solution n'est cependant pas acceptable pour la plupart des développeurs, qui ne trouveront pas de mainteneur dans chaque distribution prêt à recompiler puis empaqueter le logiciel. Cela fonctionne uniquement pour les logiciels populaires avec une grande voire très grande base d'utilisateurs.

Automatisation ?

Serait-il possible d'automatiser le travail du mainteneur, du moins une certaine partie ? Si des tests unitaires sont disponibles pour le programme, il doit être possible de créer un conteneur, compiler le programme, tester son exécution, et lancer les tests unitaires dans le conteneur, avant d'extraire puis distribuer le binaire obtenu.

Cela pourrait être mis en place soit à l'aide d'environnements chrootés, de conteneurs docker, ou même de machines virtuelles complètes.

Cependant, cela pose un grand nombre de problèmes, que nous détaillerons dans les parties à venir.

Comment choisir et obtenir automatiquement les images des distributions à supporter ?

On peut imaginer vouloir supporter les distributions majeures, i.e. la liste :

  • Debian
  • Fedora
  • openSUSE
  • Arch Linux
  • Gentoo
  • Linux Mint
  • Ubuntu
  • Alpine Linux
  • (Kali Linux, Deepin, Lubuntu, elementaryOS, ... autres distributions dérivées)

On a donc au moins 8 distributions à supporter, avec potentiellement 4 architectures (i386, x86_64, ARMv7, ARMv8/aarch64). Il faudrait donc a minima 32 conteneurs docker. Cela reste possible.

Maintenant, il faut choisir les images. On peut imaginer choisir les images suivantes :

On peut déjà voir 3 problèmes émerger :

  • Les images des distributions majeures sont par défaut dans leur dernière version, rendant impossible 'as is' le support de versions LTS comme Ubuntu 20.04. Cependant, Ubuntu supporte les tags de versions suivants : 18.04, 20.04, 22.04, 22.10, 23.04 On peut donc choisir de supporter toutes les versions disponibles comme images sur dockerhub. Cela rajoute cependant un grand nombre de conteneurs à créer
  • Les images des distributions rolling-release (archlinux, tumbleweed, même gentoo) posent problème : il est difficile de distribuer des binaires pour ce genre de distributions, car les images docker ne sont pas mises à jour tout le temps. On peut cependant considérer qu'il n'est pas forcément nécessaire de distribuer des binaires pour ces distributions ; par exemple, il est coutume sous Arch Linux (AUR) de distribuer des paquets -git et -bin, un avec les sources, compilés par makepkg, et un avec des binaires, installés par makepkg. Il n'est donc pas grave que le paquet binaire soit potentiellement non-fonctionnel pour quelque jour, les utilisateurs de ces distributions sauront se débrouiller.
  • Certaines distributions (Linux Mint par exemple) ne possèdent pas d'images docker officielles. Elles ne se comporteront pas forcément de la même manière sur certains aspects, par exemple l'image Linux Mint semble versionnée différemment des images officielles ; elle ne supporte pas les tags, ou les changements d'architecture, et contient ces informations dans son nom.

De manière plus générale, même pour les images docker officielles, cela rend notre script de déploiement très dépendant des images docker.

On pourrait réaliser le même genre de choses avec des machines virtuelles, en retrouvant automatiquement les images à la main, mais on devient dépendant des liens sur les serveurs officiels... Ou alors il faudrait utiliser un serveur communautaire contenant des images de toutes les distributions majeures. Cependant utiliser des machines virtuelles complètes serait beaucoup plus lourd et pas forcément nécessaire ici.

Quelque chose qui pourrait être plus léger que des conteneurs serait des environnements chrootés, ou des conteneurs LXC. On peut trouver des images ici de conteneurs pour LXC/chroot, configurables avec des outils comme distrobuilder.

On pourrait donc imaginer reproduire cette recherche d'images pour différents supports, mais ici nous continuerons avec Docker comme exemple (simple et efficace).

Comment installer les dépendances nécessaire à la compilation automatiquement ?

Voilà un problème plus compliqué ; il est simple d'installer automatiquement sur ce genre d'image docker des outils de build (gcc, clang, make, ...), en connaissant les commandes du gestionnaire de paquets sur chaque distribution. Il suffit de le faire une fois et c'est terminé.

Cependant, pour les dépendances à la compilation de chaque projet, c'est différent. Imaginons un projet qui dépend de libzip. Pour l'installer sur Ubuntu, il faudra utiliser le paquet libzip-dev ; sous Arch, ce sera libzip.

On peut chercher automatiquement un paquet en fonction des fichiers qu'il propose, par exemple, sous ArchLinux :

[valentin@xps ~]$ pacman -F libzip.so
extra/jre-openjdk-headless 19.0.2.u7-2
    usr/lib/jvm/java-19-openjdk/lib/libzip.so
extra/jre11-openjdk-headless 11.0.18.u10-2
    usr/lib/jvm/java-11-openjdk/lib/libzip.so
extra/jre17-openjdk-headless 17.0.6.u10-2
    usr/lib/jvm/java-17-openjdk/lib/libzip.so
extra/jre8-openjdk-headless 8.362.u09-1
    usr/lib/jvm/java-8-openjdk/jre/lib/amd64/libzip.so
extra/libzip 1.9.2-1 [installé]
    usr/lib/libzip.so
community/gephi 0.10.1-1
    usr/share/java/gephi/jre-x64/jdk-11.0.17+8-jre/lib/libzip.so

Les résultats ne sont pas très convainquants... Comment retrouver le paquet libzip que l'on cherche là-dedans ? On pourrait chercher le paquet qui propose le fichier /usr/lib/libzip.so ou /usr/local/lib/libzip.so i.e. le fichier libzip.so dans un élément de la liste des chemins utilisés par ld :

[valentin@xps ~]$ pacman -F /usr/lib/libzip.so
usr/lib/libzip.so appartient à extra/libzip 1.9.2-1

On trouve enfin ici le bon paquet. De même, sous Ubuntu :

valentin@ubuntu22:~$ apt-file search -F /usr/lib/x86_64-linux-gnu/libzip.so
libzip-dev: /usr/lib/x86_64-linux-gnu/libzip.so

On pourrait donc utiliser ce genre de technique pour automatiser l'installation des dépendances via le gestionnaire de paquets de la distribution. Certains paquets seront cependant sûrement introuvables, pour des dépendances difficiles à trouver. En général, quand un développeur indique une dépendance de compilation, il l'indique dans le readme, avec au moins un lien vers un repo git. On peut donc imaginer qu'en cas de dépendance non trouvée dans le gestionnaire de paquets, notre script connaisse l'URL du repo git sur lequel il va fallback pour télécharger et compiler la dépendance, pour plus tard la lier statiquement avec notre binaire. En effet, il n'y a là plus de problèmes à lier statiquement, car la bibliothèque dynamique n'est de toute façon pas "updatable" automatiquement sur la distribution visée. De plus, il est alors nécessaire de linker statiquement, car l'utilisateur ne pourra pas installer la même version dynamique que nous avons utilisée pour la compilation.

Il reste à définir quelle action choisir en cas de plusieurs paquets proposant la dépendance. Cette action peut être potentiellement configurable par l'utilisateur du script, par-projet et par-paquet. Cependant, il serait bien de pouvoir choisir un comportement par défaut. Je n'ai pas pu trouver de tels paquets.

Enfin, certaines dépendances de compilation ne sont pas forcément des '.so' pour LD ; il peut y avoir aussi des compilateurs spécifiques (ex. MPI), des logiciels spécifiques (ex. flex/bison)... Ces logiciels peuvent soit être également retrouvés dans le gestionnaire de paquets, soit installés localement via un script fourni par l'utilisateur si possible. Il est cependant beaucoup plus difficile d'imaginer une manière automatique de traiter toutes ces différentes dépendances spécifiques ; on doit se contenter d'un 'best-effort'.

Compilation, production de sortie

Il faut maintenant compiler dans chacun des conteneurs, pour obtenir un binaire. Pour cela, il suffit d'exécuter la commande du build system du projet, puis d'extraire le binaire du conteneur et de le renommer correctement. Mais est-ce suffisant ?

Distribuer un binaire, même pour une distribution particulière, est généralement une mauvaise chose, pour plusieurs raisons. Les raisons principales sont la gestion des dépendances (si le binaire ne se lance pas, l'utilisateur doit trouver puis installer lui-même les dépendances manquantes...) et les mises à jour (si le logiciel est mis à jour, l'utilisateur doit de lui-même aller sur le site pour re-télécharger et réinstaller un nouveau binaire...). De plus, ce n'est pas pratique à installer pour l'utilisateur, encore moins à désinstaller, etc...

Ce que l'on aimerait, c'est pouvoir générer automatiquement des .deb (distribution sous Debian, Ubuntu, Mint, ...), des .rpm (distribution sous Fedora, openSUSE, ...), ou d'autres types de paquets (même des PKGBUILD pour l'Arch User Repository). Cela ne résout pas le problème des mises à jour automatiques, mais l'améliore grandement (il existe des outils pour gérer les .deb, et les installer automatiquement), et résout complètement le problème des dépendances (un .deb doit spécifier les paquets dépendances).

Voilà en fait le coeur du problème : pour générer des .deb, il nous faut connaître les dépendances runtime de notre logiciel. Ces dépendances sont potentiellement différentes des dépendances compile-time. Même dans le cas de libzip.so vu précedemment, sous Ubuntu, le paquet libzip-dev fourni la dépendance compile-time, mais le paquet libzip4 fourni la dépendance runtime libzip.so.4.

Résolution des dépendances runtime

On peut utiliser le format de fichier ELF pour obtenir les dépendances runtime nécessaire au linker dynamique :

[valentin@xps ~]$ objdump -p svjava | grep NEEDED
  NEEDED               libzip.so.4
  NEEDED               libm.so.6
  NEEDED               libc.so.6

Il suffit alors d'isoler les dépendances, d'obtenir LD_LIBRARY_PATH la liste des chemins utilisés par le linker dynamique, et de rechercher le fichier comme précedemment :

valentin@ubuntu22:~$ apt-file search -F /usr/lib/x86_64-linux-gnu/libzip.so.4
libzip4: /usr/lib/x86_64-linux-gnu/libzip.so.4

On peut donc résoudre comme ceci les dépendances runtime nécessaire au linker dynamique.

Cependant, il reste les dépendances runtime annexes : soit des appels à dlopen()/.. de bibliothèques dynamiques (i.e. appels explicites au linker dynamique dans le code), soit des runtimes entiers (mpirun), etc... Il est difficile de résoudre ces dépendances là, et l'utilisateur doit pouvoir les spécifier avec un scripts particuliers. On peut quand même espérer pouvoir résoudre les binaires (comme mpirun).

On peut ensuite construire de manière automatique des paquets pour gestionnaires de paquets. On peut également, maintenant que l'on connaît les dépendances runtime, tester la bonne exécution de nos binaires et les tester plus en profondeur (exécution de jeux de tests unitaires, tests instrumentalisés).

La création de paquets est quelque peu hors de propos ici, mais voilà néanmoins deux exemples ci-dessous.

Création d'un paquet .deb sous Ubuntu/Debian

Il suffit de créer un dossier paquet. Dans ce dossier, on copie d'abord le contenu à installer ; par exemple pour un binaire, on peut :

valentin@ubuntu22:~/test$ mkdir -p ./usr/local/bin
valentin@ubuntu22:~/test$ cp <binaire> ./usr/local/bin

Ensuite, on crée le fichier control dans ./DEBIAN/control :

Package: <paquet>
Version: <version>
Section: base
Priority: optional
Architecture: all
Depends: libzip4
Maintainer: vhaudiquet <mail@mail.com>
Description: The test package
Homepage: https://github.com/vhaudiquet/test

Finalement, on a l'arborescence :

./test
├── DEBIAN
│   └── control
└── usr
    └── bin
        └── v_pack_test.sh

On peut construire le paquet :

valentin@ubuntu22:~$ dpkg --build test/
dpkg-deb: building package '<paquet>' in 'test.deb'.

On peut ensuite tester qu'il est installable :

valentin@ubuntu22:~$ sudo dpkg -i test.deb 
Sélection du paquet <paquet> précédemment désélectionné.
(Lecture de la base de données... 185559 fichiers et répertoires déjà installés.)
Préparation du dépaquetage de test.deb ...
Dépaquetage de <paquet> (<version>) ...
Paramétrage de <paquet> (<version>) ...

Puis vérifier la bonne exécution / tester avec des tests d'instrumentations le (les) programmes.


Création d'un PKGBUILD pour l'AUR (paquet binaire) (ArchLinux)

Il suffit de créer un fichier PKGBUILD :

# Maintainer: Valentin HAUDIQUET <youremail@domain.com>
pkgname=<paquet>
pkgver=<version>
pkgrel=1
epoch=
pkgdesc="The test package"
arch=() # ex: 'x86_64'
url=""
license=('<license>')
groups=()
depends=('libzip') # Runtime-deps
makedepends=('libzip') # Compile-time deps
checkdepends=()
optdepends=()
provides=()
conflicts=()
replaces=()
backup=()
options=()
install=
changelog=
source=("$pkgname-$pkgver.tar.gz")
noextract=()
md5sums=()
validpgpkeys=()

# NOTE : remove the function below ENTIRELY if you don't want it (cannot be left empty)
prepare() {
	echo "Prepare done !"
}

build() {
	cd "$pkgname-$pkgver"
	# Call the build system ; example : configure + make
	./configure --prefix=/usr
	make
}

check() {
	# If we need to run build tests...
	cd "$pkgname-$pkgver"
	make -k check
}

package() {
	# Package.
	cd "$pkgname-$pkgver"
	make DESTDIR="$pkgdir/" install
}

Dans ce cas, vous devez aussi fournir avec votre PKGBUIKD le fichier $pkgname-$pkgver.tar.gz. Il est aussi possible de spécifier des liens git (Github, ...) dans le tableau 'source'. Ensuite, il suffit d'exécuter :

[valentin@xps test]$ makepkg
==> Création du paquet <paquet> 0.1-1 (lun. 06 févr. 2023 22:08:23)
==> Vérification des dépendances pour l’exécution…
==> Vérification des dépendances pour la compilation…
==> Récupération des sources…
==> ERREUR : <paquet>-<version>.tar.gz n’a pas été trouvé dans le répertoire de travail et n’est pas un URL.

Bien évidemment, je n'ai pas inclu de source ici, mais la build se serait déroulée sans problème.

Conclusion

Cet article ne présente pas une méthode pour construire des binaires pour chaque plateforme, mais plutôt un concept.

Il montre comment, à partir des gestionnaires de paquets des distributions principales, et d'un peu d'informations de la part de l'utilisateur, on peut récupérer de quoi compiler puis exécuter automatiquement un logiciel écrit en C sur des plateformes virtualisées (conteneurs, dockers, vms, ...).

Ainsi, les principes de DevOps CI/CD qui sont mis en place pour d'autres langages (par exemple Java, automatisation des builds Maven/Gradle et déploiement direct vers les repos Maven) pourraient être mis en place pour des logiciels écrits en C.

Cela reste un concept, et il faudrait le mettre à l'épreuve en imaginant une preuve de concept locale, puis des outils plus performants pour exécuter ce genre de builds (mettre en place les conteneurs, chercher les dépendances, build, test, extraire..) ou compiler le schéma de build vers des Makefile, des fichiers de CI/CD (GitHub, GitLab, Travis CI, ...) pour pouvoir par exemple déployer automatiquement des .deb dans les releases GitHub ou sur des sites webs, voire directement dans les repos "utilisateurs" ou "communauté" de certaines distributions.

PS : Notes sur certains outils existants (flatpak, snap, appimage, conan, fpm, ...)

AppImage, Snap, Flatpak

Ces trois logiciels servent à distribuer des paquets sous des nouveaux formats, "multi-distributions". Ils sauvent peut-être pour vous la situation, et rendent le contenu de cet article inutile, mais il n'en est rien.

AppImage est juste une forme compacte d'image contenant des binaires et des dépendances liées statiquement / liée dynamiquement au programme mais dont vous distribuez le .so. Il s'agit uniquement d'un moyen de distribuer des binaires de type Holy Build Box, compilés avec des vieilles versions de glibc, et de les maintenir automatiquement.

Snap est en gros le même genre d'outil, en plus poussé sur le côté 'package managing' avec snapd, forcant également le lien statique des dépendances, la compilation avec une vieille version de glibc, mais en plus une isolation par rapport au système d'exploitation (type AppArmor). De plus, il est imposé par Canonical et contient du code propriétaire...

Flatpak enfin est surtout pensé pour des applications graphiques, et inclus un runtime complet i.e. leur propre version de la glibc qui doit être installé sur votre distribution. En gros, vous compilez l'application avec des composants de la version du runtime de flatpak, puis pour installer votre flatpak le démon flatpak téléchargera le runtime sur la machine de l'utilisateur puis votre paquet. C'est presque comme une sorte de docker plus léger. Les autres dépendances, non présentes dans le runtime, doivent je crois être linkées statiquement. Bref, un peu plus léger qu'un conteneur docker, mais si vous en arrivez là, autant distribuer un conteneur... Beaucoup trop de choses inutiles, très loin de la performance native qu'on attend, et encore plus de l'intégration native...

Conan

Conan se présente comme un gestionnaire de paquets C/C++. Je n'expliquerai pas comment fonctionne Conan ici, surtout que je ne l'utilise pas, mais il permet en gros de manager ses dépendances puis ses outputs de build comme des "conan packages". Cependant, même si il permet de manager des binaires sur différentes distributions, il ne permet pas par défaut de proposer une solution automatique pour les constuire. L'utilisateur doit construire cette solution à la main. Ce n'est donc pas le même objectif que dans cet article (même si Conan permet de gérer les dépendances compile-time, il les recompilera et n'utilisera donc pas les paquets du système.)

fpm

fpm est un outil qui permet de créer des .deb/.rpm etc, à partir de multiples sources. Il nécessite cependant un binaire compilé pour la distribution visé pour l'empaquetage.

Il pourrait cependant être utilisé ici pour faciliter la création des paquets en sortie, plutôt que d'utiliser un outil différent par distribution (i.e. remplacer dpkg sous Ubuntu par ex).