Docker et les containers
Après un billet sur la virtualisation et les containers, je vous proposer d’aller plus loin dans le détail de ces derniers car il s’agit désormais d’un élément incontournable dans la façon de déployer un logiciel. Je ne reviendrai pas sur ce qu’est un container puisqu’on l’a abordé dans le précédent article et vous invite à aller le consulter avant si besoin. Ici, nous allons voir l’un des principaux outils du marché, Docker, ses alternatives, comment ça marche, et l’écosystème d’outillage qui s’y est greffé pour améliorer leur utilisation.
Si j’aborde Docker principalement, et en parallèle Podman, c’est tout simplement parce que c’est la techno de container avec laquelle j’ai le plus d’expérience.
Qu’est-ce que Docker
Docker est un ensemble de produits utilisant la virtualisation côté OS pour fournir un service de Platform as a Service dont la première version date de 2013. Il est maintenu par le société Docker Inc. et possède une version communautaire libre et gratuite sous licence Apache 2.0, ainsi qu’une offre entreprise payante sous forme propriétaire. Je vous renvoie à mon article sur le Cloud Computing pour y retrouver la définition du PaaS si besoin. Il se présente sous la forme d’un daemon qui fourni une interface en ligne de commandes pour piloter les containers et les objets dont ils ont besoin, et qui fait office d’interface avec les fonctions d’isolation du Kernel Linux (notamment les cgroups et kernel namespaces).
Je parlerai évidemment du monde Linux car c’est à ça que je carbure, mais Docker est également disponible sous Microsoft Windows et MacOS. Fut un temps où Docker sous Windows reposait sur Virtualbox et une image Linux pour fonctionner, mais il me semble qu’avec le sous système Linux embarqué par Windows, l’outil est désormais natif.
Docker est écrit en Go et s’exploite sous la forme d’une commande unique : docker
qui permet de communiquer avec le daemon dockerd
. Le client peut exécuter les commandes docker
depuis n’importe quelle machine autorisée sur le réseau. Le daemon dockerd
ne tourne que sur le serveur hébergeant des containers. Enfin, les images Docker sont récupérées depuis ce qu’on appelle une registry, par défaut le serveur Docker Hub proposé par l’éditeur. Le Container Registry peut aussi être un système auto hébergé ou bien fourni par un Cloud Provider (exemple : Azure Container Registry, etc).
Le schéma suivant vous décrit de manière succincte les interactions entre les briques de Docker selon les commandes.
L’architecture de Docker du point de vue utilisation. Comme on peut le voir, tout passe par le daemon.
- Flèche rouge pour la commande
docker build
(création d’une image). Le daemon génère l’image et la stocke dans la registry locale du serveur hébergeant le servie. - Flèche verte pour la commande
docker pull
(récupération d’une image depuis un registry). Le daemon télécharge l’image depuis le service en ligne et la stocke localement. - Flèche violette pour la commande
docker run
(démarrage d’un container). Le daemon prend l’image disponible sur le système (à défaut, il la récupérera depuis une registry), puis démarre un container en se basant dessus.
Si historiquement Docker utilisait les fonctions namespace (isolation de process) et cgroups (affectation d’un accès contrôlé au CPU et mémoire), le daemon s’appuie depuis quelques versions sur sa propre implémentation via la libcontainer
qui exploite les fonctionnalités de virtualisation du kernel et permet ainsi d’avoir des interfaces d’abstraction avec libvirt
, LXC
, et systemd-nspawn
(respectivement, bibliothèque de virtualisation, containérisation, et isolation de process). Ceci est détaillé dans le schéma ci-après.
Comment le daemon Docker s’interface avec le Kernel Linux
Un container Docker étant un process du système d’exploitation, il est possible d’interagir de la même façon que n’importe quel autre process : contrôler les ressources utilisées, voir les appels système avec, etc.
Y’a pas que Docker
Comme dit précédemment, j’évoque beaucoup Docker car c’est l’un de ceux dont on entend le plus parler même s’il n’est pas l’acteur historique domaine, et c’est aussi celui avec lequel j’ai le plus d’expérience. Néanmoins, il existe d’autres implémentations de containers. Voici une petite liste sans réel classement ni exhaustivité.
- Linux VServer
- LXC, implémentation historique des containers dans Linux
- LXD est une surcouche de LXC développée par Canonical
- Podman, une implémentation similaire à Docker (les commandes sont identiques) se passant d’un daemon, proposée nativement par Red Hat et ses dérivées.
Travaillant principalement sur Fedora et avec de la famille Red Hat, j’ai tendance à utiliser nativement Podman plutôt que Docker. De ce fait, les exemples qui seront donnés dans cet article seront avec cet outil.
Qu’y a-t-il dans une image de container
Qu’est-ce qu’une image de container ?
Ce qu’on appelle une image de container, c’est un ensemble ordonné de changements sur un système de fichier racine qui correspond aux paramètres d’exécution du container. Cette définition peut paraître un peu compliquée, donc nous pouvons représenter une image Docker, sous cette forme :
Une image de container, c’est comme une pile de pancakes. Source pixabay
OK, ça n’aide pas plus mais l’idée est là : chaque changement qu’on applique lors de la création d’une image est sauvegardé dans une couche (un pancake). La couche est immuable. Lorsqu’elle est enregistrée elle ne changera jamais d’état. C’est l’ajout d’une couche supérieure qui permet d’amender la précédente. Voici un schéma explicatif pour un container qui se contente de dire : “Hello World !”.
Notre image Hello World présente deux couches. La première instruction lui dit de se baser sur l’image Alpine Linux en version latest (la dernière disponible). Cette première couche de l’image fixe donc la base du système de fichier qu’elle va contenir, elle est désormais immuable. Si je lance le container avec cette seule instruction, il ne se passera pas grand chose car le container va démarrer et … s’arrêter.
Exemple en image.
# Mon Dockerfile contient seulement "FROM alpine:latest"
$ podman build -t helloworld -f Dockerfile
STEP 1: FROM alpine:latest
STEP 2: COMMIT helloworld
--> d4ff818577b
d4ff818577bc193b309b355b02ebc9220427090057b54a59e73b79bdfe139b83
$ podman run --rm helloworld
# et soudain, rien ne se passa..
$
J’ajoute donc l’instruction du schéma ci dessous à la source de l’image. La première couche demeure inchangée, par contre il enregistre une seconde couche lui disant d’exécuter l’action : echo "hello world"
.
$ podman build -t helloworld -f Dockerfile
STEP 1: FROM alpine:latest
STEP 2: CMD ["echo", "hello world"]
--> Using cache 812cdf0f081ae1c45a1d2e3c15e038919e1626f563f2532fa6af3b4591813d3e
STEP 3: COMMIT helloworld
--> 812cdf0f081
812cdf0f081ae1c45a1d2e3c15e038919e1626f563f2532fa6af3b4591813d3e
# ce qui donne au lancement :
$ podman run --rm helloworld
hello world
$
Parcourir le contenu d’une image de container
Après ma petite introduction pancake, vous allez surement me dire : “c’est bien gentil, mais ne nous sait toujours pas ce qu’il y a dans l’image d’un container”. Et bien il suffit d’aller voir directement en se connectant dessus !
$ podman run --rm -it helloworld /bin/sh
/ #
Via cette commande, j’ai demandé de lancer le container en session interactive et de lui faire exécuter /bin/sh
pour avoir un shell. Et qu’est-ce qu’on a là dedans ?
/ # ls -l
total 56
drwxr-xr-x 2 root root 4096 Jun 15 14:34 bin
drwxr-xr-x 5 root root 360 Jul 11 20:33 dev
drwxr-xr-x 15 root root 4096 Jul 11 20:33 etc
drwxr-xr-x 2 root root 4096 Jun 15 14:34 home
drwxr-xr-x 7 root root 4096 Jun 15 14:34 lib
drwxr-xr-x 5 root root 4096 Jun 15 14:34 media
drwxr-xr-x 2 root root 4096 Jun 15 14:34 mnt
drwxr-xr-x 2 root root 4096 Jun 15 14:34 opt
dr-xr-xr-x 565 nobody nobody 0 Jul 11 20:33 proc
drwx------ 2 root root 4096 Jul 11 20:33 root
drwxr-xr-x 3 root root 4096 Jul 11 20:33 run
drwxr-xr-x 2 root root 4096 Jun 15 14:34 sbin
drwxr-xr-x 2 root root 4096 Jun 15 14:34 srv
dr-xr-xr-x 13 nobody nobody 0 Jul 9 15:06 sys
drwxrwxrwt 2 root root 4096 Jun 15 14:34 tmp
drwxr-xr-x 7 root root 4096 Jun 15 14:34 usr
drwxr-xr-x 12 root root 4096 Jun 15 14:34 var
Un filesystem Linux, on pourrait presque croire que j’ai fait un ls -l
sur mon PC, mais on a un moyen pour savoir si c’est vraiment le cas :
/ # cat /etc/alpine-release
3.14.0
# alors que je suis sur Fedora.
# Un autre détail amusant ?
# Alpine utilise le Kernel Linux de ma distrib Fedora 33 (fc33).
/ # uname -a
Linux e72f83eb9f5f 5.11.17-200.fc33.x86_64 #1 SMP Wed Apr 28 17:34:39 UTC 2021 x86_64 Linux
Voici donc ce que contient une image de container : un filesystem Linux avec les binaires et bibliothèques de base nécessaires pour produire un environnement d’exécution applicatif, mais qui utilise le Kernel de l’hôte et non le sien (à la différence d’une machine virtuelle, comme indiqué dans l’article dédié à ce sujet).
/ # grep --version
grep: unrecognized option: version
BusyBox v1.33.1 () multi-call binary.
/ # exit
# je quitte le container
$ grep --version
grep (GNU grep) 3.4
Copyright © 2020 Free Software Foundation, Inc.
Un exemple d’image multi couches
Mon petit Hello World est sympa, mais il ne permet pas de comprendre la notion de couches et le côté immuable de celles-ci. Allons donc un peu plus loin avec une image qui va partir d’une base Debian, mettre à jour les paquets, et installer Hugo pour afficher sa version à la fin.
Ce découpage permet de comprendre la notion de couches immuables, explications en partant du bas :
- Couche 1 : Le filesystem de l’image est identique à celui de l’image de base : Debian
- Couche 2 : Le filesystem est amendé en modifiant des fichiers via la commande de mise à jour des paquets installés sur l’image de base.
- Couche 3 : Le filesystem est à nouveau modifié en installant la commande
wget
- Couche 4 : Le filesystem est encore une fois modifié avec le téléchargement du paquet
deb
de Hugo - Couche 5 : Le filesystem est mis à jour avec l’installation du paquet Hugo
- Couche 6 : Le filesystem n’est pas modifié, on demande simplement au container d’afficher la version de Hugo.
Traduit avec la commande de build
de l’image, cela donne le résultat suivant :
$ podman build -t testhugo -f Dockerfile_hugo
STEP 1: FROM debian:stable
STEP 2: RUN apt-get update -y
Get:1 http://security.debian.org/debian-security stable/updates InRelease [65.4 kB]
Get:2 http://deb.debian.org/debian stable InRelease [122 kB]
Get:3 http://deb.debian.org/debian stable-updates InRelease [51.9 kB]
Get:4 http://security.debian.org/debian-security stable/updates/main amd64 Packages [293 kB]
Get:5 http://deb.debian.org/debian stable/main amd64 Packages [7907 kB]
Get:6 http://deb.debian.org/debian stable-updates/main amd64 Packages [15.2 kB]
Fetched 8454 kB in 1s (6158 kB/s)
Reading package lists...
--> 258bb370c1c
STEP 3: RUN apt-get install wget -y
Reading package lists...
Building dependency tree...
Reading state information...
The following additional packages will be installed:
ca-certificates libpcre2-8-0 libpsl5 libssl1.1 openssl publicsuffix
The following NEW packages will be installed:
ca-certificates libpcre2-8-0 libpsl5 libssl1.1 openssl publicsuffix wget
0 upgraded, 7 newly installed, 0 to remove and 0 not upgraded.
Need to get 3833 kB of archives.
(....)
done.
--> dd25ee79bd1
STEP 4: RUN wget https://github.com/gohugoio/hugo/releases/download/v0.85.0/hugo_0.85.0_Linux-64bit.deb
--2021-07-11 21:12:16-- https://github.com/gohugoio/hugo/releases/download/v0.85.0/hugo_0.85.0_Linux-64bit.deb
Resolving github.com (github.com)... 140.82.121.3
Connecting to github.com (github.com)|140.82.121.3|:443... connected.
HTTP request sent, awaiting response... 302 Found
Location: https://github-releases.githubusercontent.com/11180687/29242680-dd96-11eb-9e55-5983351b46aa?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIWNJYAX4CSVEH53A%2F20210711%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20210711T211217Z&X-Amz-Expires=300&X-Amz-Signature=b280d9ba59052eed76de8bd807ca43f7dbd3b559d2c66e3a668137d483279ed9&X-Amz-SignedHeaders=host&actor_id=0&key_id=0&repo_id=11180687&response-content-disposition=attachment%3B%20filename%3Dhugo_0.85.0_Linux-64bit.deb&response-content-type=application%2Foctet-stream [following]
--2021-07-11 21:12:17-- https://github-releases.githubusercontent.com/11180687/29242680-dd96-11eb-9e55-5983351b46aa?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIWNJYAX4CSVEH53A%2F20210711%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20210711T211217Z&X-Amz-Expires=300&X-Amz-Signature=b280d9ba59052eed76de8bd807ca43f7dbd3b559d2c66e3a668137d483279ed9&X-Amz-SignedHeaders=host&actor_id=0&key_id=0&repo_id=11180687&response-content-disposition=attachment%3B%20filename%3Dhugo_0.85.0_Linux-64bit.deb&response-content-type=application%2Foctet-stream
Resolving github-releases.githubusercontent.com (github-releases.githubusercontent.com)... 185.199.109.154, 185.199.110.154, 185.199.108.154, ...
Connecting to github-releases.githubusercontent.com (github-releases.githubusercontent.com)|185.199.109.154|:443... connected.
HTTP request sent, awaiting response... 200 OK
Length: 13891226 (13M) [application/octet-stream]
Saving to: 'hugo_0.85.0_Linux-64bit.deb'
0K .......... .......... .......... .......... .......... 0% 4.67M 3s
50K .......... .......... .......... .......... .......... 0% 6.71M 2s
(...)
2021-07-11 21:12:18 (18.9 MB/s) - 'hugo_0.85.0_Linux-64bit.deb' saved [13891226/13891226]
--> 18ee877328b
STEP 5: RUN apt install ./hugo_0.85.0_Linux-64bit.deb -y
WARNING: apt does not have a stable CLI interface. Use with caution in scripts.
Reading package lists...
Building dependency tree...
Reading state information...
The following NEW packages will be installed:
hugo
0 upgraded, 1 newly installed, 0 to remove and 0 not upgraded.
Need to get 0 B/13.9 MB of archives.
After this operation, 42.5 MB of additional disk space will be used.
Get:1 /hugo_0.85.0_Linux-64bit.deb hugo amd64 0.85.0 [13.9 MB]
debconf: delaying package configuration, since apt-utils is not installed
Selecting previously unselected package hugo.
(Reading database ... 7079 files and directories currently installed.)
Preparing to unpack /hugo_0.85.0_Linux-64bit.deb ...
Unpacking hugo (0.85.0) ...
Setting up hugo (0.85.0) ...
--> a2701628b8f
STEP 6: CMD ["/usr/local/bin/hugo", "version"]
STEP 7: COMMIT testhugo
--> 3367fc63c64
3367fc63c6411552cdaf261535b635700bb88adca57510ae6f2915832ceb6838
Lançons le container, il affiche la version de Hugo.
$ podman run --rm testhugo
hugo v0.85.0-724D5DB5 linux/amd64 BuildDate=2021-07-05T10:46:28Z VendorInfo=gohugoio
Quel est l’intérêt de ces images ?
Je pense que vous devez l’avoir deviné par vous-même avec l’exemple de l’image Hugo. Lorsqu’on build l’image, le fichier source est exécuté dans l’ordre des étapes et applique les modifications demandées à l’image de base pour produire à la fin un ensemble applicatif réutilisable.
Dans un premier temps, cela signifie désormais que vous pouvez utiliser votre image Hugo n’importe où, elle s’exécutera toujours de la même façon sans avoir besoin de réinstaller l’outil et ses dépendances. Son lancement sera immédiat et ne nécessitera plus aucune installation préalable en dehors du téléchargement de l’image depuis un registry.
Autre élément intéressant, l’image peut servir à plusieurs containers. La même image est donc utilisée, ce qui évite la multiplicité des données la composant sur le disque.
Ensuite, il y a une mécanique de cache lors de la construction. Si je relance la commande de build
, il verra qu’il n’y a aucune différence par rapport à l’état actuel et conservera donc celui-ci :
$ podman build -t testhugo -f Dockerfile_hugo
STEP 1: FROM debian:stable
STEP 2: RUN apt-get update -y
--> Using cache a174a35fb009e866a96028b5ab28b3b73b6f5daae0b63d4cf27cbfe040db6816
--> a174a35fb00
STEP 3: RUN apt-get install wget -y
--> Using cache 0894bd26b26519557b9abceb5d020dfd89723e14a1bd9486665dcc2018a315a7
--> 0894bd26b26
STEP 4: RUN wget https://github.com/gohugoio/hugo/releases/download/v0.85.0/hugo_0.85.0_Linux-64bit.deb
--> Using cache 874a24164260d2dea13c7663f18a82ad9d777930c114a79fc5ca99bd541921ad
--> 874a2416426
STEP 5: RUN apt install ./hugo_0.85.0_Linux-64bit.deb -y
--> Using cache bf4a2a9bd12839a0491e7a3f54507298fda35baf5b5ccac6fd37e031bd2d4d4d
--> bf4a2a9bd12
STEP 6: CMD ["/usr/local/bin/hugo", "version"]
--> Using cache dadc6da33c44d417691ca2e8f5e15cf8d964172bc4c4d39a7e4b2777f0d8c8b0
STEP 7: COMMIT testhugo
--> dadc6da33c4
dadc6da33c44d417691ca2e8f5e15cf8d964172bc4c4d39a7e4b2777f0d8c8b0
Mieux encore : ce cache est conservé pour chaque application de couche. Dans la première version de cette image, je faisais afficher l’aide de Hugo et non sa version. L’état de la commande CMD
à la fin a été sauvegardé dans le cache de podman
et lorsque je le remets, il ne recalcule rien, il se contente de reprendre cette du cache. (on peut évidemment lui dire de l’ignorer)
$ podman build -t testhugo -f Dockerfile_hugo
STEP 1: FROM debian:stable
(...)
STEP 6: CMD ["/usr/local/bin/hugo", "--help"]
--> Using cache c01cec6f2227786408c098e2de7ac1ed3636359739c5e125e70d0295851d6b6b
STEP 7: COMMIT testhugo
--> c01cec6f222
c01cec6f2227786408c098e2de7ac1ed3636359739c5e125e70d0295851d6b6b
Pour résumer
Une image de container c’est donc :
- Une image de base permettant d’interagir avec le Kernel de l’hôte
- Diverses successions de modifications pour avoir l’environnement d’exécution nécessaire à notre application
- Un objet immuable qui repartira toujours de son état initial lorsqu’un container est démarré ou redémarré
Dans un prochain billet, nous verrons comment un container interagi avec le système. Notamment sur la partie réseau et disque. Egalement, nous aborderons dans un autre article dédié la notion d’orchestration qui permet de tirer partie de la puissance du modèle des containers.