La jungle des conteneurs

Docker n'est pas seul

Quand on parle de conteneurs, bien souvent 3 technologies nous viennent à l'esprit : docker, kubernetes et lxc.
Cependant nous avons beaucoup plus de technologie 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épendants du développement de LXC. Par la suite, docker à lancer libcontainers, qui permettait de lancer les conteneurs sans LXC.
Cependant à cette époque, beaucoup de reproche était fait à docker, notamment pour son binaire monolithique. Le même binaire servait à la fois de CLI, et à la fois de daemon.
Docker à bien écouté se reproche, et à commencé par découper le daemon et la CLI.

Par la suite, l'Open Container Initiative fut créé, afin de créer un standard du conteneur applicatif. Docker à été un acteur principal dans cette définition.

De cette initiative, est née 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 :

docker-containerd-shim

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.