Docker From Scratch : Et si on arrêtait d'utiliser Docker comme une VM ?

Un Docker = Un process

Lors de ma découverte de docker, il y a 3 ans, le message était assez clair sur le fait qu'un docker ne devait contenir qu'un process et un seul. Cependant, on ne comprend pas tout de suite tout ce que cela implique.

PID 1

En tant qu'admin sys, j'ai intégré depuis longtemps l'importance d'init, le pid 1 traditionnel sous Linux. Il m'a fallu, à l'époque, quelques jours pour comprendre tous les impacts de l'absence d'init dans un conteneur. Aujourd'hui, cette problématique est davantage connue et discutée. De nombreux workaround ont été mis en place comme par exemple l'utilisation de supervisord pour gérer les différents services ou encore plus récemment, l'introduction par Docker d'un init (tini) (nouvelle option --init) : https://medium.com/@nagarwal/an-init-system-inside-the-docker-container-3821ee233f4b

Construisant mes propres conteneurs, j'ai donc scrupuleusement séparé tous les services et surtout complètement modifié la façon de démarrer les dits services : oublié les scripts d'init qui passaient des variables d'environnment, les démons qui forkaient et qui changeaient d'utilisateur pour qu'un service ne tourne pas en root. Tout ce travail devait être reporté dans le Dockerfile.

Une philosophie assez peu respectée

En regardant, les images déposées sur le docker hub, on constate que beaucoup d'entre elles utilisent Docker comme des mini VM, à savoir une stack nginx/phpfpm/mariadb peut se retrouver dans un seul conteneur, le tout gèrer au mieux par supervisord au pire par un magnifique script shell passé en entrypoint qui va se charger de démarrer tous les services.

Tout ceci ayant pour conséquence que la taille des conteneurs peut facilement atteindre plusieurs centaines de méga, pour exemple l'image de owncloud/server qui fait 721 MB. Dans la recherche d'une optimisation de la taille des conteneurs, Docker s'est mis à massivement utilisé Alpine Linux : https://thenewstack.io/alpine-linux-heart-docker/ , une distribution Linux très légère basée sur busybox et musl libc. Elle a été pensé pour fonctionner sans disque dur ce qui permet de l'utiliser dans des boitiers de type routeur, vpn ou firewall : https://fosdem.org/2017/schedule/event/building_a_distro_with_musl_libc/.

Musl libc a l'avantage d'être plus légère que la glibc (une comparaison est disponible sur le site officiel : http://www.etalabs.net/compare_libcs.html). Les conteneurs basés sur alpine seront donc plus légers, cependant comme cette librairie a été moins testé que la glibc, des bug inconnus peuvent être présents. Les images docker basées sur Alpine embarque donc dans le Dockerfile la recompilation de leur logiciel avec musl libc comme, par exemple, l'image officiel de nginx.

Docker FROM scratch

Pourtant, il existe une solution lorsque que notre objectif est de minimiser la taille du conteneur :

FROM scratch

Le but d'une image Docker from scratch est de créer une image vide (sans OS). On peut la créer de cette façon :

tar cv --files-from /dev/null | docker import - severine/scratch

On constatera que cette image fait bien 0 Kb en lançant un docker images :

REPOSITORY                TAG                 IMAGE ID            CREATED             SIZE
severine/scratch          latest              0b3f13b3a072        2 weeks ago         0 B

Une application en GO

On va pouvoir utiliser cette image pour y lancer son binaire qui devra être compilé statiquement. Ce binaire embarquera alors toutes ses dépendances. C'est parfait pour une application en GO !

En reprenant cet exemple issu de http://blog.xebia.com/create-the-smallest-possible-docker-container/ :

package main

import (
        "fmt"
        "net/http"
)

func helloHandler(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintln(w, "Hello from Go in minimal Docker container")
}

func main() {
        http.HandleFunc("/", helloHandler)

        fmt.Println("Started, serving at 8080")
        err := http.ListenAndServe(":8080", nil)
        if err != nil {
                panic("ListenAndServe: " + err.Error())
        }
}

On compilera ce code en static :

go build -ldflags -linkmode external -extldflags -static hello.go

Et on créera notre Dockerfile :

FROM severine/scratch
ADD hello /hello
ENTRYPOINT ["/hello"]

Après le build, on pourra vérifier la taille de notre conteneur :

severine/hello   latest              9e478eb422dd        2 weeks ago         7.05 MB

On le démarre ensuite en exposant le port :

docker run -p 8080:8080 severine/hello

Notre application est up dans un conteneur de 7 MB soit la taille du binaire.

Pour toute application qui peut être compilée en statique, on pourra la faire tourner dans son conteneur et donc de manière isolée sans avoir besoin d'embarquer tout un OS dans le conteneur. Si la compilation statique n'est pas possible, il faut se contraindre à séparer correctement tous les services et arrêter de considérer docker comme une VM.


Article précédent : Utilisez et abusez des hooks de Git