Que vous soyez un développeur, un DevOps ou un administrateur système, vous n’avez pas échappé à la « révolution des conteneurs », et parmi les solutions de conteneurisation disponibles vous avez probablement opté pour Docker ! Mais êtes-vous sûr que Docker est toujours la meilleure solution ? La plus adaptée à vos utilisateurs, à vos contraintes de sécurité ? Nous vous proposons de découvrir Singularity comme alternative à Docker.
Si Docker excelle pour encapsuler des applications complexes, des services réseau (Web, bases de données...) et des architectures à base de microservices, Docker peut rester complexe pour qui n’est pas un développeur chevronné, habitué à jongler avec les processus, les UID/GID et les systèmes de fichiers. En outre, proposer aux utilisateurs un accès direct à une plateforme supportant Docker pose de véritables challenges concernant la sécurité. Le démon dockerd doit-il fonctionner avec le compte root ou pas ? Pour éviter que les utilisateurs ne jonglent eux-mêmes avec les montages, le réseau ou l’utilisation des ressources, la solution de conteneurisation proposée par Singularity s’avère plus simple et plus sûre.
1. Présentation
Singularity est une solution de conteneurisation open source créée en 2015 par une équipe de l’Université de Berkeley dirigée par G.Kurtzer (qui est aussi le créateur de la distribution CentOS !). Singularity est développé depuis 2017 par la société Sylabs.io [1]. L’objectif initial est de proposer une solution de conteneurisation adaptée aux besoins des scientifiques qui doivent exécuter des applications conteneurisées sur des clusters de calculs (HPC). Mais bien sûr, cette solution peut trouver un plus large public.
L’installation de Singularity se fait via un package nommé singularity ou singularity-container, disponible avec votre distribution Linux préférée. Les conteneurs sont instanciés à partir d’« images » et les images utilisées par Singularity sont stockées à l’aide d’un système de fichiers nommé SquashFS, vous aurez donc besoin d’installer aussi le package squashfs-tools.
2. « Isolation » ou « intégration »
Pour bien comprendre les différences fondamentales entre Docker et Singularity, exécutons quelques commandes :
Ce premier exemple illustre clairement le principe d’isolation des processus imposé par Docker : le conteneur possède sa propre vision du système de fichiers et le répertoire /tmp, vu du conteneur, n’a rien à voir avec le répertoire /tmp de la machine hôte !
Si nous voulons que le conteneur puisse lire et écrire sur le véritable système de fichiers de la machine hôte, nous ajoutons une ou plusieurs options -v (ou --mount) à la commande précédente :
Mais le fichier créé ne m’appartient pas !
En effet, le conteneur a été lancé par l’utilisateur 1000, mais le fichier a été créé par root ! Là aussi, ce comportement est dû à l’isolation du processus conteneurisé : les utilisateurs et groupes définis dans le conteneur sont différents de ceux définis sur la machine hôte. Bien sûr, il existe, là aussi une option (-u) qui permet de choisir l’utilisateur et le groupe qui va exécuter la commande dans le conteneur :
Maintenant que nous avons vu comment Docker isolait les processus conteneurisés, regardons l’approche choisie par Singularity. Tout d’abord, une bonne nouvelle : Singularity peut utiliser les images Docker ! La commande suivante va télécharger l’image busybox:latest depuis le Docker Hub et va la convertir au format SIF (Singularity Image Format) (nous étudierons ce format plus tard) :
L’image convertie s’appelle ./busybox_latest.sif. Utilisons-la pour exécuter les commandes identiques à notre test Docker :
Les résultats des commandes parlent d’eux-mêmes :
- un conteneur lancé par Singularity partage une partie de son système de fichiers avec celui de la machine hôte (quelles parties ? nous le verrons au §4.3) ;
- le propriétaire du processus lancé dans le conteneur est l’utilisateur qui crée le conteneur avec la commande singularity.
Comme le dit la documentation officielle de Singularity : « the philosophy of Singularity is Integration over Isolation ». C’est-à-dire qu’un utilisateur qui dispose d’une image SIF peut exécuter, dans son répertoire personnel, le logiciel contenu dans l’image : inutile pour cet utilisateur de se préoccuper d’éventuels montages de volumes et de se perdre dans des correspondances entre UID/GID. Le tout sous l’œil bienveillant de l’administrateur qui n’a pas déployé de démon possédant les droits de « root » !
3. Gestion des images
3.1 Obtenir une image
Qui dit conteneurs, dit images ! Et là, il faut bien reconnaître que le nombre d’images disponibles publiquement sur Docker Hub est impressionnant. Singularity dispose d’un principe équivalent, appelé la Library, qui est un dépôt dans lequel vous pouvez stocker vos propres images.
La commande singularity pull permet de télécharger des images ayant des provenances multiples :
L’aide en ligne de cette commande montre clairement la syntaxe à utiliser pour obtenir des images depuis Docker Hub ou la Library (Singularity Hub est une autre implémentation de la Library).
Par exemple :
3.2 Créer une nouvelle image
Il y a plusieurs méthodes pour obtenir une image au format SIF. Tout d’abord, il est possible de convertir une image depuis le format Docker (c’est aussi ce qu’a fait la commande pull précédente) :
L’image obtenue est un exécutable :
L’option --sandbox permet d’obtenir une arborescence complète du contenu de l’image plutôt que l’image elle-même :
Si vous avez déjà des images dans le cache local de Docker, il est possible de les utiliser ainsi :
Il y a là une différence fondamentale avec les images Docker : ces dernières sont composées de Layers contenant des fichiers accessibles en lecture seule, associés à des métadonnées (celles qu’on définit dans le fameux fichier Dockerfile). Les images Docker ne sont pas exécutables !
Le format SIF est différent, plus simple, mais moins optimisé : deux images ne partageront jamais de fichiers contrairement aux Layers des images Docker. Par contre, les images SIF sont exécutables comme nous l’avons montré. Il est aussi possible d’obtenir les métadonnées d’une image :
L’affichage ci-dessus parle d’un deffile : quel est cet attribut ?
3.3 Les « deffiles »
Les deffiles sont les équivalents des Dockerfiles ! La syntaxe est donc différente, mais tout aussi simple (enfin c’est mon avis) [2].
Un deffile est constitué d’un Header et de Sections. Le nom des Sections débute par un pourcent (%). Singularity supporte (comme Docker) la notion de multi-stage Build (Build à plusieurs étapes), dans ce cas, il y aura plusieurs couples {Header+Sections}.
Un Header doit contenir la directive Boostrap dont la valeur indique le nom de l’Agent qui doit obtenir l’image de base. Par exemple, à partir d’une image Docker, à partir d’une image de la Library, à partir d’une base Debian, CentOS... Les autres paramètres du Header dépendent du choix de l’agent de Bootstrap.
Les Sections sont généralement plus nombreuses, mais sont optionnelles. Le fichier hello.def suivant illustre la syntaxe d’un deffile :
On suppose que le compilateur go est installé sur votre machine, l’image est construite ainsi (il faut être root) :
Testons notre image :
3.4 Contenu de l’image
Nous pouvons obtenir les métadonnées de l’image ainsi :
On retrouve bien notre nom d’auteur !
Nous pouvons aussi obtenir l’aide associée à l’image :
Nous pouvons explorer le contenu de l’image en créant le répertoire hello/ ainsi :
Tout semble clair : quand on lance le conteneur avec la commande singularity run, on invoque le script shell nommé /.singularity.d/actions/run. Ce script « source » les scripts situés sous /.singularity.d/env/ (qui contiennent, entre autres, des variables d’environnement), puis il lance nos commandes qui ont été placées dans /.singularity/runscript !
4. Gestion des conteneurs
4.1 Création de conteneurs
Maintenant que nous savons créer nos propres images, que pouvons-nous en faire ? Singularity propose plusieurs commandes.
singularity run a pour rôle de créer un conteneur et d’exécuter les commandes prévues dans la rubrique %runscript. Il est possible de passer des paramètres à ces commandes :
singularity exec crée un conteneur, mais lance la commande donnée en paramètre plutôt que la commande par défaut :
Enfin, singularity shell lance en conteneur et ouvre un shell qui permet d’avoir accès à son système de fichiers.
4.2 Cas des processus de « services »
Les commandes singularity run et exec lancent des processus en « avant-plan », elles ne conviennent pas au lancement de processus qui rendent des « services » et qui doivent être lancés en « arrière-plan ». Dans ce cas, il faut utiliser la commande singularity instance pour gérer ces conteneurs.
Par exemple, pour lancer un serveur nginx dans un conteneur, il faut exécuter :
Cette commande invoque normalement les commandes définies dans la section %startscript de l’image (il est possible de surcharger le nom de la commande à démarrer) sinon le programme sinit est exécuté. Dans l’image nginx au format de Docker, il n’y a naturellement pas de rubrique %startscript ! Il faut donc exécuter explicitement nginx dans ce conteneur. Pour cela, nous utilisons la commande exec avec le paramètre instance:// qui permet d’exécuter une commande dans un conteneur actif :
Mais nous voyons s’afficher plusieurs erreurs :
- la première signifie qu’il faut être root pour lancer nginx ;
- la deuxième souligne le fait que nginx ne peut créer de répertoire dans le système de fichiers du conteneur car, effectivement, les images contiennent un système de fichiers accessible en lecture seule !
Nous allons résoudre ces deux problèmes, mais auparavant, supprimons cette instance :
4.3 Overlays et stockage persistant
Vérifions qu'une image n'est accessible qu'en lecture seule :
Pourtant, certaines opérations modifiantes sont possibles :
Nous avons pu modifier notre répertoire courant, car, quand un conteneur est créé, Singularity monte les répertoires /tmp, /var/tmp, $HOME et $PWD en lecture/écriture à l’intérieur du conteneur ! Cela facilite le principe d’intégration énoncé au début de cet article !
Imaginons que nous voulions rendre accessible en écriture d’autres répertoires, il faut pour cela utiliser des Overlays qui vont se superposer à des répertoires de l’image :
Le fichier bonjour.txt se trouve sous my_overlay/upper/data/ (mais il faut être root pour y accéder). Si nous relançons un conteneur, nous retrouvons le contenu du répertoire /data :
Mais revenons maintenant à notre problème de nginx qui ne pouvait pas démarrer, faute d’accès à un système de fichiers accessible en écriture ! Nous pourrions utiliser le principe des Overlays, mais nous pouvons aussi créer un Overlay « à la volée » pour l’intégralité de l’image. Le lancement de nginx s’effectue ainsi :
4.4 Accès réseau
Par défaut, les conteneurs créés par Singularity disposent de la même configuration réseau que la machine hôte, mais il est possible de leur donner un autre nom de machine (--hostname <host>), un autre serveur de noms (--dns <server>) et de les connecter sur un autre réseau de type « bridge » (--net).
Comme Singularity s’appuie sur des plugins réseau compatibles avec le standard CNI (Container Network Interface), il est possible de donner des paramètres à ces plugins via l’option --network-args.
Démontrons cela sur deux brefs exemples.
Créons un réseau de type Bridge pour isoler des conteneurs, mais de telle sorte qu'ils puissent échanger entre eux via le réseau :
Dans un autre shell, exécutons la commande suivante :
Nous voulons maintenant effectuer un « Port mapping » entre le port 8080 de la machine hôte et le port 80 d'un serveur nginx encapsulé dans un conteneur :
5. Sécurité
Par défaut, utilisateurs et conteneurs sont indignes de confiance (untrusted), ainsi un utilisateur non privilégié qui lance un conteneur ne peut devenir privilégié dans un conteneur et les processus à l’intérieur d’un conteneur ne disposent d’aucune Capability [3] et ne peuvent acquérir de nouveaux privilèges.
5.1 Utilisateurs et Capabilities
Nous allons montrer l'impact des Capabilities sur les conteneurs. Tout d'abord, vérifiez que la commande ping suivante va échouer, mais qu'elle va fonctionner quand on est root :
Nous allons maintenant autoriser l'utilisateur à exécuter des conteneurs avec la Capability CAP_NET_RAW (je suis l’utilisateur jd) :
Pour activer une Capability, il faut utiliser l'option --add-caps :
Supprimez la Capability ainsi :
Pour les curieux, la commande singularity capability a modifié le fichier /etc/singularity/capability.json. Singularity dispose d’autres fichiers de configuration qui permettent de gérer les Control Groups, de localiser les librairies d’accès aux GPU, de configurer le comportement par défaut lors de la création des conteneurs. La commande singularity config permet de gérer le fichier de configuration principal de Singularity.
5.2 Considérations supplémentaires
Il est possible de signer et vérifier les signatures des images utilisées par Singularity (on utilise pour cela les commandes singularity key | sign | verify).
Dans le cas où certains conteneurs nécessitent des privilèges root, sachez qu’il existe une option nommée fakeroot, équivalente du mode rootless de Docker qui permet de donner aux processus conteneurisés les privilèges root, mais uniquement sur les objets appartenant à leur namespace.
Conclusion
Si Docker s’adresse principalement aux développeurs et DevOps qui livrent des applications devant être strictement isolées, Singularity, par sa simplicité et son accent mis sur l’intégration avec le système, est une solution plus adaptée pour les utilisateurs scientifiques. Ceux-ci peuvent ainsi déployer leurs applications conteneurisées sur les clusters de calcul sans menace pour la sécurité du système.
Références
[1] Singularity sur le site de Sylabs.io : https://sylabs.io/singularity/
[2] Syntaxe des « deffiles » : https://sylabs.io/guides/3.8/user-guide/definition_files.html
[3] Liste et définition des Capabilities : https://man7.org/linux/man-pages/man7/capabilities.7.html