Quand on parle de conteneurs, bien souvent 3 technologies nous viennent à l’esprit : docker
, kubernetes
et lxc
.
Cependant nous avons beaucoup plus de technologies que ça.
Nous ne parlerons pas ici de ce que j’appelle les conteneurs systèmes (Conteneurs comme LXC qui viennent avec tout l’OS), mais des conteneurs applicatifs (comme docker par exemple). Nous essaierons de comprendre le fonctionnement des conteneurs, en regardant les spécifications de ceux-ci, et en regardant les alternatives qui existe.
Docker
C’est indéniable, les conteneurs applicatifs sont ce qu’ils sont grâce à docker.
Historique simplifié
Docker était à la base une sorte d’interface de gestion à LXC, toujours pour faire du conteneurs applicatifs, mais avec comme backend LXC, il était donc très dépendant du développement de LXC. Par la suite, docker à lancer libcontainers
, qui permettait de lancer les conteneurs sans LXC.
Cependant à cette époque, beaucoup de reproches étaient fait à docker, notamment pour son binaire monolithique. Le même binaire servait à la fois de CLI, et à la fois de daemon.
Docker a bien écouté ce reproche, et a commencé par découper le daemon et la CLI.
Par la suite, l’Open Container Initiative fut créée, afin de créer un standard du conteneur applicatif. Docker a été un acteur principal dans cette définition.
De cette initiative, sont nées 2 spécifications, le runtime-spec, et le image-spec, qui permettent la standardisation du lancement d’un conteneur, et de la création d’image, c’est ce qu’on appelle le modèle OCI.
Aujourd’hui
Docker n’est aujourd’hui plus ce gros bloc monolithique qu’il était, et on pourrait même dire qu’il ne fait plus grand chose.
Docker utilise désormais containerd
et runc
pour lancer ces conteneur, et si je ne me trompe pas, il ne garde pour lui que la gestion des images et des réseaux.
Les runtimes et outils
Avec ce découpage, nous avons maintenant beaucoup de runtime et d’outils qui existent.
Runtime de bas niveau
Les runtimes de bas niveau, sont les outils qui permettrons l’exécution du conteneur au format OCI, rien de plus, rien de moins.
Nous en avons beaucoup, avec certains qui sont très particuliers :
- runc : est le runtime issue de l’OCI, celui par défaut
- crun : L’implémentation de
runc
en C qui semble environ 2 fois plus rapide que runc - gvisor : Le runtime de google, avec une isolation supplémentaire
- kata-container : Un runtime qui crée un VM par conteneur pour une isolation complète
- runV : Idem (mais ne semble plus maintenu)
- clear-container : Idem (ne semble plus maintenu)
- Et surement beaucoup d’autres …
Runtime de haut niveau
Les runtimes de haut niveau ont un rôle plus global, il permettent la gestion des images, de monter les volumes, de gérer les couches de fs, etc …
Nous en avons quelques-un également :
- containerd : Celui utilisé par défaut avec docker et k3s, il fonctionne également en mode API exposé par un daemon.
- conmon : Celui utilisé par défaut par podman, comme podman, il est daemonless.
- cri-o : CRI-O est l’implémentation du modèle CRI de kubernetes, fonctionne avec k8s.
- …
Les controlleurs
Les controlleurs sont le nom que je leur donne, je ne sais pas s’il y a vraiment un nom pour ceci. Ce sont les outils qui permettent de controler les conteneurs. Nous trouvons dans ce niveau, des orchestrateurs et des simples lanceurs de conteneurs.
C’est ce que tout le monde connais :
- docker
- podman
- kubernetes et dérivés (k3s, minikube etc …)
- crictl : Pour controler cri-o
- ctr : Pour controler containerd
- rkt
- …
Les builders d’image
Désormais il existe beaucoup de builder d’image :
- docker : Toujours aussi bien foutu avec les Dockerfiles
- buildah : Mon petit chouchou, rootless, daemonless, compatible dockerfile, que demandé de plus (un excellent tutoriel )
- img : Pareil, rootless et daemonless, basé sur les dockerfiles, et en plus cross build
- orca-build : Je n’ai pas testé celui ci, mais compatible avec dockerfiles, semble un peu plus lourd à mettre en place.
- buildkit
- kaniko
- …
Mais alors, que choisir ?
Tout n’est pas changeable pour tout, par exemple, pour docker ou podman vous n’avez pas le choix, il faut utiliser containerd
(docker) ou conmon
(podman), mais vous pouvez changer de runtime de bas niveau, par exemple en fonction de votre besoin, nous pouvons utiliser crun
pour gagner en performance, ou alors gvisor
ou kata-container
pour une meilleur isolation.
Pour kubernetes, là nous avons plusieurs choix, nous pouvons utiliser docker
, ou alors directement containerd
. Nous pouvons également choisir d’utiliser cri-o
qui est selon moi le couple gagnant. Derrière cri-o
, il est possible de changer de runtime de bas niveau également. Nous pourions également utiliser rkt
, je l’ai mis dans les controlleurs, mais en réalité rkt est totalement monolithique, et est à la fois un runtime de haut et bas niveaux.
K3s de base utilise containerd
directement, je n’ai pas regarder mais je suppose que tout est changeable également, je pense que le trio k3s
, cri-o
et crun
doit faire une très bonne équipe.
Pour ce qui est du build, si vous utilisez docker ou podman, autant utiliser ceux intégré, à moins que comme moi, vous voulez externaliser les builds, dans ce cas du daemonless peux être top, comme buildah
ou img
.
Cas pratiques
Ici nous allons voir quelques exemples, il n’ont pas forcément de lien entre eux.
Utilisation de runc à la main
Runc est totalement utilisable sans docker, même si peu pratique, car c’est simplement un lanceur de conteneur, il ne gère pas les images ni le réseau.
Nous allons donc créer un petit conteneur alpine, pour ce faire nous commençons par créer notre répertoire :
$ mkdir ~/runctest/alpine -p
$ cd ~/runctest/alpine
Puis nous générons les specs OCI (génération d’un fichier config.json) :
$ runc spec --rootless
$ ls
config.json
Nous téléchargeons l’image minirootfs d’alpine :
$ wget http://dl-cdn.alpinelinux.org/alpine/v3.12/releases/x86_64/alpine-minirootfs-3.12.0-x86_64.tar.gz
$ mkdir rootfs
$ tar xzf alpine-minirootfs-3.12.0-x86_64.tar.gz -C rootfs
et nous pouvons lancer notre conteneur :
$ runc run alpine
#
Et nous voilà dans notre conteneur, fonctionnel, et avec le réseau. Cependant le réseau est en mode host, pas de namespace utilisé :
# ip addr
[...]
2: wlp2s0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP qlen 1000
link/ether 34:f3:9a:a4:7a:9f brd ff:ff:ff:ff:ff:ff
inet 192.168.1.50/24 brd 192.168.1.255 scope global dynamic wlp2s0
valid_lft 76769sec preferred_lft 76769sec
inet6 fe80::2bf9:b989:530c:e0f8/64 scope link
valid_lft forever preferred_lft forever
[...]
Je trouve que c’est moyen, allons donc modifier un peu le fichier de configuration :
[...]
"root": {
"path": "rootfs",
"readonly": false
},
[...]
"namespaces": [
{
"type": "pid"
},
{
"type": "ipc"
},
{
"type": "uts"
},
{
"type": "mount"
},
{
"type": "user"
},
{
"type": "network"
}
],
[...]
J’ai ajouté dans la partie namespaces, un namespace de type network
. J’ai également modifier le readonly, car de base le FS est en lecture seul, pas toujours pratique pour les tests.
Désormais nous n’avons plus de réseau sur notre conteneur :
$ runc run alpine
# ip addr
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
inet 127.0.0.1/8 scope host lo
valid_lft forever preferred_lft forever
inet6 ::1/128 scope host
valid_lft forever preferred_lft forever
Ajoutons du réseau
Puisque nous sommes en rootless, nous allons utiliser slirp4netns :
On lance notre conteneur dans un premier terminal :
$ runc run --pid-file /tmp/alpine.pid alpine
# ping 9.9.9.9
Puis dans un second :
$ slirp4netns --configure --mtu=65520 --disable-host-loopback $(cat /tmp/alpine.pid) tap0
sent tapfd=5 for tap0
received tapfd=5
Starting slirp
* MTU: 65520
* Network: 10.0.2.0
* Netmask: 255.255.255.0
* Gateway: 10.0.2.2
* DNS: 10.0.2.3
* Recommended IP: 10.0.2.100
Si nous retournons dans notre conteneur :
# ip addr
ip addr
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
inet 127.0.0.1/8 scope host lo
valid_lft forever preferred_lft forever
inet6 ::1/128 scope host
valid_lft forever preferred_lft forever
3: tap0: <BROADCAST,UP,LOWER_UP> mtu 65520 qdisc fq_codel state UNKNOWN qlen 1000
link/ether 4e:3f:be:b7:dd:04 brd ff:ff:ff:ff:ff:ff
inet 10.0.2.100/24 brd 10.0.2.255 scope global tap0
valid_lft forever preferred_lft forever
inet6 fd00::4c3f:beff:feb7:dd04/64 scope global dynamic
valid_lft 86389sec preferred_lft 14389sec
inet6 fe80::4c3f:beff:feb7:dd04/64 scope link
valid_lft forever preferred_lft forever
# apk update
fetch http://dl-cdn.alpinelinux.org/alpine/v3.12/main/x86_64/APKINDEX.tar.gz
fetch http://dl-cdn.alpinelinux.org/alpine/v3.12/community/x86_64/APKINDEX.tar.gz
v3.12.0-86-g64c1a9607a [http://dl-cdn.alpinelinux.org/alpine/v3.12/main]
v3.12.0-89-g636a7dc328 [http://dl-cdn.alpinelinux.org/alpine/v3.12/community]
OK: 12736 distinct packages available
Gérer vos conteneurs avec containerd
Si vous avez utilisez docker, vous utilisez containerd sans le savoir.
Containerd est également un daemon, qui lancera une instance runc
en mode detach via containerd-shim
. En gros nous nous retrouvons avec ceci :
Nous pouvons cependant directement utiliser containerd, c’est plus limité, mais c’est fonctionnel.
Pull d’image
Avec containerd, il faut indiqué le registry sur lequel on télécharger l’image et surtout le tag de celle-ci :
$ ctr -a /var/run/docker/containerd/containerd.sock images pull docker.io/library/alpine:latest
docker.io/library/alpine:latest: resolved |++++++++++++++++++++++++++++++++++++++|
index-sha256:185518070891758909c9f839cf4ca393ee977ac378609f700f60a771a2dfe321: done |++++++++++++++++++++++++++++++++++++++|
manifest-sha256:a15790640a6690aa1730c38cf0a440e2aa44aaca9b0e8931a9f2b0d7cc90fd65: done |++++++++++++++++++++++++++++++++++++++|
layer-sha256:df20fa9351a15782c64e6dddb2d4a6f50bf6d3688060a34c4014b0d9a752eb4c: done |++++++++++++++++++++++++++++++++++++++|
config-sha256:a24bb4013296f61e89ba57005a7b3e52274d8edd3ae2077d04395f806b63d83e: done |++++++++++++++++++++++++++++++++++++++|
elapsed: 2.1 s total: 2.7 Mi (1.3 MiB/s)
unpacking linux/amd64 sha256:185518070891758909c9f839cf4ca393ee977ac378609f700f60a771a2dfe321...
done
Lancement du conteneur
On commence par créer notre conteneur
$ ctr -a /var/run/docker/containerd/containerd.sock run -tdocker.io/library/alpine:latest test sh
#
Rien de bien compliqué
Utilisation de gvisor
Installation
$ apt install golang
$ echo "module runsc" > go.mod
$ GO111MODULE=on go get -v gvisor.dev/gvisor/runsc@go
$ CGO_ENABLED=0 GO111MODULE=on go install -v gvisor.dev/gvisor/runsc
$ cp go/bin/runsc /usr/local/bin/
Puis on modifie ou crée le fichier /etc/docker/daemon.json :
{
"default-runtime": "runc",
"runtimes": {
"gvisor": {
"path": "/usr/local/bin/runsc"
}
}
}
On relance docker :
$ systemctl restart docker
Si on vérifie :
$ docker info
[...]
Runtimes: gvisor runc
Default Runtime: runc
[...]
Utilisation
Nous le lancerons ici un conteneur alpine avec runc, et l’autre avec gvisor, afin de pouvoir analyser le tout :
$ docker run -d --rm -m 128M --cpuset-cpus 0 alpine ping 1.1.1.1
$ docker run -d --rm --runtime gvisor -m 128M --cpuset-cpus 0 alpine ping 9.9.9.9
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
fcf50706c70d alpine "ping 9.9.9.9" 4 seconds ago Up 3 seconds great_davinci
084da1b082c6 alpine "ping 1.1.1.1" 17 seconds ago Up 16 seconds heuristic_euclid
Première chose que nous pouvons constater, c’est qu’on ne vois que le processus lancé via runc (ping 1.1.1.1
) sur l’hôte, l’autre est invisible (ping 9.9.9.9
) :
$ ps aux | grep ping
root 20302 0.0 0.0 1568 4 ? Ss 10:19 0:00 ping 1.1.1.1
root 20379 0.0 0.0 8160 736 pts/0 S+ 10:27 0:00 grep --color=auto ping
Ensuite nous pouvons voir que même docker est incapable de voir ce qui se passe dans le conteneur :
$ docker top fc
UID PID PPID C STIME TTY TIME CMD
root 1 0 0 14:20 ? 00:00:08 /sbin/init
$ docker top 08
UID PID PPID C STIME TTY TIME CMD
root 25759 25739 0 15:45 ? 00:00:00 ping 1.1.1.1
Nous pouvons voir également une différence dans la gestion du CPU et de la RAM :
$ docker exec -ti fc free -m
total used free shared buff/cache available
Mem: 128 1 126 0 0 126
Swap: 0 0 0
$ docker exec -ti 08 free -m
total used free shared buff/cache available
Mem: 7844 2540 2733 630 2571 4556
Swap: 0 0 0
Sur notre conteneur lancé avec runc
, nous voyons toute la ram disponible sur la machine, avec gvisor
seul la ram vraiment disponible.
Pareil donc pour le CPU :
$ docker exec -ti fc cat /proc/cpuinfo | grep -c processor
1
$ docker exec -ti 08 cat /proc/cpuinfo | grep -c processor
4
Seulement 1 cpu n’est dispo sur le conteneur gvisor
contre les 4 sur celui lancé avec runc
.
De plus, si nous utilisons --pid host
, seul le conteneur lancé par runc
sera capable de voir les process de l’hôte, et non le conteneur lancé avec gvisor
:
$ docker run -ti --rm --runtime gvisor --pid host alpine ps aux
PID USER TIME COMMAND
1 root 0:00 ps aux
Tandis qu’avec runc
:
$ docker run -ti --rm --runtime runc --pid host alpine ps aux
PID USER TIME COMMAND
1 root 0:09 {systemd} /sbin/init
2 root 0:00 [kthreadd]
3 root 0:00 [rcu_gp]
4 root 0:00 [rcu_par_gp]
6 root 0:00 [kworker/0:0H-kb]
8 root 0:00 [mm_percpu_wq]
9 root 0:00 [ksoftirqd/0]
10 root 0:00 [rcuc/0]
11 root 0:01 [rcu_preempt]
12 root 0:00 [rcub/0]
13 root 0:00 [migration/0]
14 root 0:00 [idle_inject/0]
[...]
Nous pouvons également comparé le noyau Linux :
$ docker run -ti --rm --runtime runc alpine uname -a
Linux 20b700aaf75a 5.6.16-1-MANJARO #1 SMP PREEMPT Wed Jun 3 14:26:28 UTC 2020 x86_64 Linux
$ docker run -ti --rm --runtime gvisor alpine uname -a
Linux 72c518a36cb0 4.4.0 #1 SMP Sun Jan 10 15:06:54 PST 2016 x86_64 Linux
Nous voyons clairement que gvisor
intègre son propre noyau, ce qui prouve que l’isolation est bien là.
Quelques ressoures
Conclusion
Faire cette article m’a poussé à faire pas mal de recherche, et j’ai testé beaucoup de chose.
Je suis en se moment en train de tester le trio docker
, containerd
et gvisor
, qui me semble le meilleur compromis en terme d’isolation et de performances.