Docker et les conteneurs logiciels font maintenant partie intégrante de la vie du sysadmin. Rappelons que les conteneurs permettent généralement d'isoler les processus, souvent des services réseau, afin de les détacher du système et de les faire s'exécuter dans des environnements distincts. Ainsi la ou les applications en question « vivent » dans leur petit monde, ignorant tout de la réalité du système faisant fonctionner le conteneur, mais aussi des autres applications dans leur propre environnement contenu. Cette mécanique peut être également très intéressante et avantageuse pour le développeur embarqué...
Avant toute chose, précisons ici que nous parlons de GNU/Linux, et bien que les mécanismes de conteneurs et Docker existent dans d'autres systèmes, nous nous limiterons à cet environnement. La distribution n'a pas réellement d'importance, et ceci est valable également pour WSL, du moment que vous disposez d'une installation fonctionnelle de Docker (comprendre « que vous avez installé le paquet qui va bien »).
Je ne m'étendrai pas non plus sur les principes de fonctionnement, les différentes déclinaisons des architectures, l'orchestration ou tout autre contexte qui nous écarterait de notre sujet principal. L'objet de cet article est simplement de voir ce que Docker peut nous permettre de simplifier dans nos activités de développement et de construction de firmware.
À ce propos justement. Si vous êtes comme moi, à intervalles réguliers, vous êtes coincé entre deux options lorsque votre distribution (Debian en particulier, mais pas seulement) commence à prendre des rides. De plus en plus de paquets affichent des numéros de version qui paraissent venir d'un autre âge et de plus en plus d'outils, compilés localement, demandent des bibliothèques dont vous ne disposez pas ou plus matures que celles actuellement installées. Là, deux choix s'offrent à vous, un dist-upgrade à vos risques et périls, soit une réinstallation bien propre assortie d'une sympathique période de reconfiguration et de copies de fichiers. Ne nous voilons pas la face, j'utilise GNU/Linux depuis 25 ans et l'expérience m'a prouvé que, peu importe le choix qu'on fera, ceci n'est jamais exempt de problèmes, de frayeurs, de larmes et d'énervement en tous genres.
Mais voilà, il y a toujours un projet qui demande un CMake plus récent, une libc qui n'a pas 5 ans de retard ou un utilitaire que vous n'avez vraiment pas envie d'installer avec un horrible ./configure && make && make install qui ne ferait qu'empirer les choses. Idéalement, vous auriez besoin d'un système dans le système, facile à installer et à utiliser, qui ne repose pas sur une machine virtuelle pour des raisons de performance, mais assez souple pour vous permettre de jongler joyeusement ou d'essayer des choses sans rien casser. Ça tombe bien, parce que Docker permet exactement de faire cela !
Note : L'utilisation de conteneurs ne doit pas être confondue avec l'émulation ou la virtualisation. L'émulation consiste à simuler un matériel (comme une Pi sur un PC avec QEMU par exemple) pour y faire fonctionner tout un système. La virtualisation repose sur un partage de ressources matérielles entre plusieurs systèmes, mais le ou les systèmes virtualisés utilisent leur noyau et leurs pilotes. Avec un conteneur Docker, l'environnement exécuté n'a pas de noyau, mais uniquement un système de fichiers propre, séparé de celui de l'hôte faisant fonctionner Docker.
1. Construire son ou ses images
La logique derrière Docker est importante et la comprendre le plus rapidement possible vous permettra de ne pas perdre votre latin. Tout est une question de terminologie :
- un Dockerfile est une description permettant de créer une image du système qui sera utilisée dans un conteneur ;
- une image est une représentation du système tel qu'il prendra place dans un conteneur. Voyez cela comme un modèle, un template ou une classe qui sera instanciée ;
- et un conteneur est une instance d'une image qui peut être créée, démarrée et stoppée.
En d'autres termes, vous utilisez un Dockerfile pour créer une image, mais chassez de votre esprit que cette image est le système, comme le serait une image d'un système de fichiers. C'est un simple point de départ et vous n'utiliserez pas directement l'image en vous servant du système qui s'exécutera dans le conteneur. Pourquoi faire aussi compliqué ? C'est simple, imaginez que vous construisiez le système qui vous convient, avec tous les éléments qui vous intéressent et même les configurations dont vous avez l'habitude. Mais vous avez la ferme intention d'utiliser cette base pour plus d'une expérimentation. Vous allez donc créer plusieurs conteneurs, utilisant tous cette même image comme point de départ, mais vivant leurs vies bien à eux. Un peu comme utiliser une bonne recette de cupcake, bien peaufinée et au point, et décliner le toping à loisir ensuite.
Exactement comme pour des cupcakes, vous allez réaliser votre appareil (le terme qui fait « pro » en pâtisserie pour désigner un mélange homogène de plusieurs ingrédients) à partir d'une recette. Avec Docker, cette recette, c'est le Dockerfile et comme en cuisine, ceci s'accompagne d'un contexte, en l'occurrence ce qui est mis à disposition sur le plan de travail, à savoir ce qu'on trouve dans le répertoire courant (c'est important, nous allons le voir dans un instant avec la copie de fichiers).
Ce Dockerfile (par défaut Dockerfile) se présente comme un simple fichier texte, mi-description, mi-script. Il doit commencer par l'instruction FROM permettant de préciser une image de base. Celle-ci peut être n'importe quelle image valide, mais sera généralement tirée du dépôt public (https://hub.docker.com/search?type=image). Pour cet exemple, nous utiliserons une image de base Debian Bullseye (Debian 11.1) :
N'hésitez pas à visiter la page web du dépôt, on y trouve de tout, et rien ne vous empêchera d'utiliser une image OpenSuse dans un conteneur sur une Debian ou encore Ubuntu sur une Fedora. Il existe des images très basiques comme celle-ci avec un système de fichiers minimal, mais également d'autres orientées langages de programmation (Python, Go, Ruby, etc.), bases de données (MySQL, PostgreSQL, InfluxDB, etc.) ou encore frameworks applicatifs (Drupal, Gradle, Couchbase, .NET, etc.).
L'image Debian Bullseye nous fournit un environnement simple que nous pouvons personnaliser en exécutant une série de commandes, à commencer par une installation de paquets :
RUN est l'instruction permettant d'exécuter les commandes dans l'environnement et donc dans le « système » qui constituera l'image. Ceci n'est pas très différent de ce que vous feriez vous-même juste après l'installation d'une distribution, mais sera forcément spécifique à l'image de base. Ici, DEBIAN_FRONTEND nous permet de préciser à apt-get qu'il ne s'agit pas d'une session interactive et que tout devra se dérouler automatiquement. Ainsi après un petit update, nous spécifions simplement les paquets selon nos envies et préférences.
D'autres commandes peuvent (doivent) également être spécifiées. Pour créer un nouvel utilisateur par exemple :
Ceci créera denis, définira son mot de passe et le placera dans les groupes sudo (pour passer root) et dialout (pour l'accès aux /dev/TTYUSB* et /dev/ttyACM*) ainsi que plugdev pour accéder aux périphériques plug'n'play (mon Dieu, que ce terme fait vieux). Nous pouvons également ajuster la localisation :
Nous aurons ainsi directement un système localisé avec des dates et un jeu de caractères adéquats (comprendre « français »). Notez l'utilisation de ENV permettant de définir une variable d'environnement (LANG) valable pour toutes les instructions suivantes dans le Dockerfile.
Nous basculons ensuite sur l'utilisateur défini précédemment pour toutes les autres instructions du fichier :
Puis nous personnalisons son compte en copiant quelques fichiers avec COPY :
Attention ! L'instruction COPY ne peut agir QUE sur le répertoire courant, qui est le fameux contexte de construction. Ceci signifie que home/denis/.vimrc n'est pas le .vimrc présent dans le répertoire personnel de l'utilisateur qui exécutera la construction, mais dans un sous-répertoire home/denis/ du répertoire courant. En d'autres termes, il convient de copier, dans le répertoire où vous créerez le Dockerfile, tous les éléments que vous désirerez placer dans l'image. Ceci est non seulement plus propre, mais permet également de ne pas se mélanger les pinceaux entre ses fichiers *rc de l'hôte qui exécute Docker et ceux à destination de l'image.
Enfin, une autre instruction pourra être utile :
Celle-ci a pour effet de définir, pour toutes les instructions qui suivent, le répertoire courant. J'ai pour habitude de laisser cette ligne dans mes Dockerfiles, même s'il n'y a rien ensuite. L'intérêt est de pouvoir ajouter des RUN pour ajouter des commandes qui devront être exécutées dans ce répertoire comme, typiquement, un git clone ou un téléchargement de fichiers.
Vous pouvez stocker tout cela dans un fichier Dockerfile dans le répertoire courant (idéalement prévu à cet effet), mais personnellement, je préfère nommer mes Dockerfiles en fonction de ce à quoi ils servent, ici simplement mydebian.dock puisque c'est une configuration très générique.
On pourra ensuite créer l'image avec :
Note : si docker vous refuse l'exécution sous prétexte que vous n'avez pas les permissions adéquates, placez simplement l'utilisateur dans le groupe docker, déconnectez-vous et reconnectez-vous. N'utilisez pas sudo, ce n'est ni nécessaire ni souhaitable.
Cette opération dure un certain temps, car l'image de base doit être téléchargée, les paquets installés et la configuration effectuée. Mais construire une autre image identique (ce qui présente peu d'intérêt), avec un autre nom (-t) ira beaucoup plus vite. docker build -t autre_image -f mydebian.dock . sera quasiment instantané, puisque le travail a déjà été fait. La commande docker build prend en argument un nom d'image, un nom de Dockerfile (-f) et un répertoire de construction, ici le répertoire courant (.).
Vous pouvez lister toutes les images construites sur votre système avec :
Remarquez que nous n'avons créé ici que deux images, articledocker et autre_image, mais que debian figure également dans la liste. De plus, nos deux images ont la même ID. Ceci vient du fait que Docker travaille avec des couches ou layers. Il n'est pas nécessaire de dupliquer l'ensemble des éléments des images. Les tailles données ne correspondent pas non plus à l'espace disque effectivement occupé, et c'est précisément là l'intérêt du système. debian a été téléchargée comme image de base et pourra être utilisée plus rapidement avec une déclinaison de notre Dockerfile. Ou supprimée avec docker rmi ou docker image rm suivie du nom de l'image, si vous le souhaitez.
2. Création, exécution, connexion...
À ce stade, nous avons deux images parfaitement identiques, basées sur le même Dockerfile, mais nous n'avons pas de conteneur « vivant ». Il est donc temps d'utiliser ces images pour profiter des bienfaits de Docker. Il est important avant cela de comprendre qu'un conteneur doit être vu comme l’instanciation d'une image et que celui-ci peut être créé/exécuté (run), arrêté (stop) et (re)démarré (start). La distinction est importante, car la première exécution va créer le conteneur, mais quand vous le quitterez, celui-ci sera arrêté et non détruit. Vous n'allez donc pas l'exécuter à nouveau, mais le redémarrer pour l'utiliser une nouvelle fois.
Mais commençons par le début, en créant le premier conteneur :
Nous utilisons la commande run qui s'appelle ainsi, car elle prévue pour « exécuter une commande dans un conteneur ». Mais comme nous demandons un fonctionnement interactif (-i) et l'utilisation d'un pseudo-TTY (-t) sans préciser de commande, nous nous retrouvons avec un shell. --name nous permet de donner un nom au conteneur et articledocker est tout simplement le nom de l'image à utiliser.
Nous nous retrouvons donc avec une ligne de commandes d'un Bash exécuté dans le conteneur, sous Debian Bullseye. Mais ce n'est pas un « vrai » système, /boot est vide, pas de /var/log/kern.log et presque rien dans /dev.
Plions-nous d'un touch TOTO pour créer rapidement un fichier dans le répertoire courant puis quittons le shell avec exit ou Ctrl+D, nous revenons à notre ligne de commandes précédente de la machine locale. Là, nous pouvons exécuter à nouveau docker run en spécifiant un nouveau nom de conteneur :
Remarquez le nom d'hôte différent dans le prompt, et pour cause, ce n'est pas le même « système » et notre fichier TOTO n'est plus là. Nous venons de créer un second conteneur, à partir de la même image et, après avoir quitté avec exit, nous pouvons lister les conteneurs avec :
Nous avons deux conteneurs, deb1 et deb2, utilisant l'image articledocker et dans un état « quitté ». Notez bien qu'ils utilisent articledocker comme base, mais que cette image n'est pas un support de stockage pour le « système ». J'insiste, car c'est une source de confusion, ceci n'a rien à voir avec une image de système de fichiers comme le .img d'une Raspberry Pi qu'on pourrait monter « en loopback » (losetup) pour y faire des modifications.
Pour reaccéder à un conteneur, nous pourrions être tentés de faire :
Mais run ne peut être réutilisé, c'est l'autre point de confusion avec Docker. Le conteneur existe et est simplement arrêté. Pour le redémarrer, il faut utiliser :
Nous retrouvons, bien entendu, notre TOTO et le conteneur est à nouveau en marche. Les options -a et -i (combinées en -ai) correspondent respectivement au fait de s'attacher automatiquement et de travailler de façon interactive. Autrement dit, les sorties STDOUT/STDERR du conteneur sont envoyées sur notre terminal et notre STDIN devient également celui du conteneur.
Pour laisser fonctionner le conteneur tout en retournant à notre shell d'origine, vous pouvez utiliser la séquence Ctrl+P puis Ctrl+Q. Un docker container ls (l'option -a pour all permet d'afficher aussi les conteneurs à l'arrêt) vous montrera votre conteneur actif. Pour y retourner, ou plutôt vous y rattacher, utilisez simplement docker attach deb1. Vous pouvez également stopper un conteneur détaché avec docker stop deb1. Remarquez que l'opération peut paraître lente, mais en réalité, c'est un délai de 10 secondes qui est automatiquement appliqué. Vous pouvez préciser un autre délai avec l'option -t, dont 0.
Enfin, il peut vous venir l'envie de faire un brin de ménage. Vous pouvez donc supprimer un conteneur avec :
Et une image avec :
Mais si vous essayez de supprimer une image utilisée par un conteneur, Docker vous rappellera à l'ordre sans ménagement :
Voici qui conclut la partie sur l'utilisation de base de Docker, pour rapidement créer des conteneurs pour des manipulations sans risques avec un environnement sur mesure, par projet. Ici, on peut se permettre des sudo make install sans remords et autres légèretés honteuses du même genre. Notez au passage qu'il est même possible de créer des images à partir d'un conteneur (docker commit) et donc de se servir de cette mécanique pour créer des « points de sauvegarde ».
3. Accès au matériel
Pouvoir disposer d'un environnement de développement personnalisé à souhait et ne plus être dépendant de la distribution ou de la version de la distribution qu'on utilise est une bonne chose, tout comme la possibilité de temporairement et impunément saccager tout ce qui nous chante. Mais le développement sur microcontrôleur ou à destination d'un système embarqué n'est pas qu'une affaire de SDK, de chaînes de compilation et de BSP. Il faut pouvoir, à un moment, accéder au matériel pour charger un firmware, déboguer ou même tout simplement communiquer avec la plateforme via une application ou un outil spécifique.
Les conteneurs existent principalement pour répondre aux problématiques d'isolation d'applications. Il n'est donc guère étonnant qu'accéder au matériel ne soit pas possible par défaut, et passer outre n'est pas aussi souple d'utilisation que le reste des fonctionnalités. De base, si vous essayer d'accéder à un périphérique, comme une carte STM32 Nucleo avec la suite open source des ST-Link tools, on vous remettra promptement à votre place ainsi :
Il existe plusieurs solutions vous permettant de contourner le problème et donc d'utiliser un périphérique USB, comme une carte de développement accessible avec des outils spécifiques (st-util, OpenOCD, PicoTool, etc.) par exemple, ou un port série comme celui proposé par une carte Arduino ou ESP8266/ESP32. La plus simple et brutale d'entre elles consiste à tout simplement oublier une partie de la sécurité [1] et de l'isolation fournies par les conteneurs, en utilisant l'option --privileged de docker run. Celle-ci permet de donner à un conteneur la permission d'accéder à n'importe quel périphérique de la machine local. Et le résultat est, bien entendu, celui espéré :
Depuis notre conteneur, l'outil st-info des ST-Link tools voit effectivement la carte Nucleo et son STM32F411 sans le moindre problème. Bien que nous ne soyons pas dans une logique d'isolation de service ou d'application, mais dans une simple démarche pratique, ceci pourrait être toléré. Mais ce n'en est pas moins une mauvaise idée et une très mauvaise habitude. Mieux vaut ne pas se comporter comme un homme de Cro-Magnon et ne permettre que l'accès au périphérique que nous utilisons.
Ceci est possible en utilisant l'option --device= suivie d'un chemin vers le périphérique en question. Sur l'hôte, et toujours avec notre exemple de STM32 Nucleo, nous commençons par identifier le périphérique avec lsusb :
Notre carte est sur le bus USB 002 et est le périphérique 009. Nous utilisons ces éléments avec cette nouvelle option :
Magnifique, nous avons accès à la Nucleo sans compromettre notre sécurité. Ceci fonctionne même avec OpenOCD qui ne trouve rien à redire :
Ceci s'appliquera également aux cartes communiquant via un port série comme les Arduino, apparaissant sous GNU/Linux via des entrées comme /dev/ttyUSB0 et dev/ttyACM0. Il suffira d'utiliser --device=/dev/ttyUSB0 et le tour sera joué. Seul petit problème, si nous débranchons la carte et la rebranchons, elle apparaîtra probablement sous un autre numéro (et possiblement sur un autre bus selon où elle sera reconnectée). Pire encore, si c'est une Raspberry Pi Pico, elle apparaît et disparaît en fonction du démarrage en mode BOOTSEL ou non. Cette technique fonctionne en jonglant avec les suppressions et exécutions de conteneurs, mais elle est relativement rigide et ne se prête que très mal à un cycle habituel de développement sur microcontrôleur ou SBC. Nous pouvons faire mieux !
Une autre option envisageable est -v permettant de lier (bind) un volume ou, en d'autres termes, faire apparaître une partie du système de fichiers « réel » (local) dans le conteneur. On pourra donc utiliser quelque chose comme -v /dev/bus/usb:/dev/bus/usb, ou plus généralement -v /dev:/dev, pour se retrouver avec un /dev dans le conteneur, identique à celui de la machine elle-même. Sauf que :
Avoir accès aux pseudofichiers de /dev est une chose, mais avoir la permission d'y lire et écrire en est une autre. Le fait que l'utilisateur, dans et hors du conteneur, fasse partie de dialout ou plugdev importe peu, la restriction vient de cgroups (pour control groups), une fonctionnalité du noyau Linux, utilisée par Docker afin de limiter, gérer et isoler l'utilisation des ressources. Il nous faut donc spécifier une option --device-cgroup-rule, en plus de -v, pour changer les permissions accordées. Pour les périphériques USB, nous pouvons utiliser :
La règle cgroups est « c 189:* rmw » où :
- c désigne un périphérique de type « caractère » ;
- 189 est le numéro majeur du périphérique Linux ;
- :* désigne tous les numéros mineurs ;
- et rmw indique des permissions de lecture (r), création de nœuds (m pour mknod) et d'écriture (w).
La notion de numéro de périphérique majeur et mineur est inhérente au noyau Linux et à la façon dont les périphériques sont associés aux pilotes. Ces numéros sont fixes et définis dans les sources du noyau. Mais vous pouvez également les trouver en listant les informations complètes des entrées (ou nœuds) dans /dev :
c indique un périphérique en mode caractère, 188 le numéro majeur et 0 le mineur. Nous pouvons également consulter le contenu de /proc/devices pour obtenir une liste complète :
Ainsi, en liant /dev et en autorisant les accès aux périphériques en mode caractère dont le majeur est 166, 188 ou 189, nous obtenons un conteneur capable de dialoguer avec les périphériques USB (via la libUSB), ainsi que ceux apparaissant sous la forme de /dev/ttyUSB* et /dev/ttyACM*. Notre commande docker run devient donc :
Ceci règle notre problème de permissions et nous permet donc de développer, programmer et déboguer pour n'importe quelle cible depuis nos conteneurs.
Pour conclure sur cette partie liée au matériel et au développement, sans doute souhaiterez-vous également transférer des fichiers (sources) vers et depuis vos conteneurs. Ceci n'est largement pas aussi compliqué que l'accès au matériel USB et sera l'affaire d'une simple commande.
Pour copier depuis le système de fichiers local vers le conteneur :
Où deb2 est le nom du conteneur. Et dans le sens opposé :
Notez qu'il n'y a pas d'options permettant une copie récursive de répertoire puisque c'est automatiquement le cas.
4. M'sieur ? J'ai plus de place !
Jouer avec Docker et se créer tout un tas d'environnements et de systèmes à torturer dans des conteneurs c'est bien, c'est pratique et c'est amusant. Ce qui l'est moins, en revanche, c'est l'espace occupé par toutes ces données. Arrivera donc le moment fatidique où l'espace disque viendra à faire défaut. Vous serez alors tenté de déplacer tous ces conteneurs dans un autre emplacement que /var/lib/docker, sur un SSD supplémentaire monté, par exemple dans /mnt/SSD2T.
Déplacer ses « petites » affaires n'est pas très difficile, si l'on s'en réfère à la documentation officielle et non à des posts de blogs faits par des utilisateurs qui sont, passez-moi l'expression, rien de moins que de gros gorets. L'utilisateur Debian bien rôdé aura tendance à aller voir dans /etc/default/docker, mais là, une triste surprise l'y attend : « THIS FILE DOES NOT APPLY TO SYSTEMD ». C'est amusant comme certaines choses qui vous tapent sur les nerfs ont tendance à revenir vous taquiner à la moindre occasion... Merci Lennart de nous éloigner chaque jour un peu plus de la philosophie UNIX ! Vraiment merci (sarcasmes) !
Mais ce dont je parle, ce sont plutôt les documentations vous invitant joyeusement à éditer /lib/systemd/system/docker.service, histoire d'y glisser une option -g obsolète suivi d'un chemin. Non ! Non, re-non, et encore NON ! On ne touche pas à un fichier de configuration du système, géré par le gestionnaire de paquets, et qui se fera écraser à la moindre mise à jour. Et on n'entre pas non plus de chemin en dur dans un tel script. Systemd est déjà suffisamment irritant sans qu'on se sente obligé d'empirer le supplice. Je ne parle même pas de l'aspect assez présomptueux consistant à se dire que les développeurs de Docker n'ont pas pensé au problème...
Il y a une façon de faire cela proprement et cela commence par stopper le/les service(s) Docker :
On peut ensuite créer le fichier /etc/docker/daemon.json, contenant :
Nous précisons ainsi, proprement, où se trouve la racine de l'arborescence de données et nous n'avons plus qu'à la déplacer à l'endroit désigné, avant de redémarrer le service :
À toutes fins utiles, un petit docker image inspect suivi du nom d'une image vous permettra de vous assurer que l'emplacement est bien référencé (WorkDir), ou en utilisant simplement la commande docker info -f '{{ .DockerRootDir}}'.
5. Pour conclure
Pour être tout à fait honnête avec vous, nous n'avons fait qu'effleurer le sujet ici. Docker permet de faire énormément de choses, comme placer des limitations (CPU, mémoire, etc.) sur les images et les conteneurs, sans compter toutes les options et solutions d'orchestration disponibles pour automatiser vos processus. Cette solution s'est démocratisée auprès des administrateurs système depuis très longtemps, mais il est encore rare de voir cette technologie utilisée dans le milieu du développement embarqué. Pourtant, ceci constituerait une solution très intéressante et pratique pour la diffusion des SDK comme celui du Raspberry Pi Pico, et certains projets l'ont d'ores et déjà compris (comme le firmware Proxmark de RFID Research Group [2]).
J'ai longtemps ignoré Docker, n'y voyant initialement que peu d'intérêt dans mon domaine de prédilection, mais prendre le temps de se pencher sur le sujet s'est avéré être un investissement très rentable. Je dispose de conteneurs pour mes développements ARM baremetal, Raspberry Pi Pico, ESP-IDF, STM32H7 sur Game & Watch, etc., et tout ce petit monde est rangé, ordonné et incroyablement facile à maintenir. Aujourd'hui, je pense que j'aurai bien du mal à m'en passer...
Références
[1] https://blog.trailofbits.com/2019/07/19/understanding-docker-container-escapes/