Comment optimiser les images Docker pour la production ?

Dockeriser son application devient de plus en plus une norme, mais qui dit norme dit également dérive. Dans cette article nous allons parler d’un problème de taille. Oui, de taille. Vous avez peut être déjà utilisé des images qui pouvaient faire 100Mo, 500Mo, parfois même plusieurs Go, alors que derrière le process exécuté ne représente que 5 à 10% de l’espace occupé.

Comment optimiser les images Docker pour la production ?

Certains diront que aujourd’hui entre le débit internet et l’espace disque dont nous disposons ce n’est pas très important. Cependant en embarquant la terre entière dans vos livrables vous prenez plus de risque d’embarquer une faille de sécurité, un bug ou un outil qui peut un jour disparaître et impacter votre application alors que vous n’en aviez même pas besoin.

À travers un exemple, je vous propose de découvrir ce qui fait qu’une image peut rapidement être énorme et comment se débarrasser tout ce qui occupe de l’espace pour rien.

Avant d’entrer dans le vif du sujet, je vous propose de vous prêter à l’exercice à l’aveugle. Votre objectif, dockeriser le front de l’application hello-world NodeJS et créer l’image la plus petite possible. Pour connaitre la taille de votre image, vous pouvez utiliser la commande suivante :

docker image <image_name>

Vous pourrez alors comparer votre image à celle obtenu à la fin de cet article. Réussirez-vous à faire plus petit ?

Version 1 : Image docker de base ubuntu

Pour cette première version de notre image docker, je ne réfléchis pas trop. Je calque la façon dont je construis et lance l’application sans docker sur mon ordinateur. Je travaille sous ubuntu 18.04, et pour déployer un site internet j’utilise nginx. Le front est développé en nodeJS, pour le construire j’ai besoin de npm et nodeJS. Dans un Dockerfile, ça donne :

FROM ubuntu:18.04

# port à exposer pour accéder à l'application
EXPOSE 80

# on installe les outils nécessaire à la construction et à l'exécution
RUN apt update &amp;&amp; apt install node npm nginx -y

# on se place dans un dossier de travail et on y copie tout le code de l'application
WORKDIR /app
COPY . ./

# On construit l'application et on la déplace dans le bon dossier pour nginx
RUN npm install
RUN npm run build
RUN cp dist/* /var/www

# Commande lancée lors du run de l'image docker
CMD ["nginx", "-g", "daemon off;"]

Je construis et je vérifie la taille de mon image :

docker build -t hello-step1 .
...
docker images hello-step1
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
hello-step1         latest              e649dbd95c46        29 seconds ago      606MB

Ça fait un livrable de 606Mo pour un front qui faisait 2.4Mo. Sans surprise, c’est beaucoup trop. Je suis parti de Ubuntu qui prend déjà beaucoup d’espace. Pour faire tourner un front, il n’y a pas besoin d’un OS comme Ubuntu.

Version 2 : Image docker de base nginx alpine

Si vous cherchez dans la liste des images officiel du docker hub, vous trouverez déjà plein de logiciel dockerisé en libre service. Et si jamais celui que vous chercher n’est pas dans la liste des images officielles, vous avez encore de grande chance d’en trouver une version non officielle mais quand même supportée par la communauté.

Nous avons besoin de nginx pour exposer notre front. Mais au lieu de l’installer nous même dans notre Dockerfile, ne serait-il pas plus simple de chercher si il existe une image déjà construite comme il faut avec nginx dedans ? Ça tombe bien, c’est le cas ! Une image officiel nginx est disponible sur le docker hub. Dans la liste des tags associé à l’image, nous voyons que la dernière est la 1.17.5. En revanche c’est étrange, il existe la même version suffixé par alpine avec une taille indiqué à 8Mo au lieu de 50Mo. Qu’est-ce que cela signifie ?

Comment optimiser les images Docker pour la production ?

Alpine Linux est une version très petite de Linux. Elle est basée sur MUSL, le standard libc le plus léger qui existe. Cet OS est très utilisé comme image de base dans le monde des conteneurs car il permet de réduire considérablement la taille de nos images finales. Lorsqu’un tag docker et suffixé par alpine, l’auteur nous informe qu’il s’est basé sur l’image docker d’Alpine Linux plutôt qu’un Debian comme pour l’image docker nginx:1.17.5.

C’est parfait pour nous, voyons voir ce que cela donne si on part d’une image nginx alpine.

FROM nginx:1.17.5-alpine

# port à exposer pour accéder à l'application
EXPOSE 80

# Sous Alpine, on utilise plus apt mais apk
RUN apk add nodejs npm

# on se place dans un dossier de travail et on y copie tout le code de l'application
WORKDIR /app
COPY . ./

# On construit l'application et on la déplace dans le bon dossier pour nginx
RUN npm install
RUN npm run build
RUN cp -r dist/* /usr/share/nginx/html
docker build -t hello-step2 .
...
docker images hello-step2
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
hello-step2         latest              767388e5b567        24 seconds ago      318MB

318Mo ! C’est mieux mais c’est encore beaucoup… Mince alors j’ai pourtant utilisé une version alpine de nginx ! Il est indiqué que cette image fait seulement 8Mo… Mais attend ! Quand j’y repense, l’image Ubuntu dont je suis parti en version 1 faisait 42Mo seulement elle aussi ! Comment j’ai pu atteindre 600Mo sur mon image ?

Version 3 : Utiliser un conteneur docker de build

Dans nos Dockerfiles, la première chose qu’on fait c’est installer les outils nécessaire à la construction de notre application : NodeJS et npm. Des outils qui eux même peuvent avoir des dépendances qu’ils installent à leur tour. On lance ensuite la construction de l’application, qui va télécharger toutes les librairies nodeJS utilisées dans le projet pour générer le front à exposer.

Le problème c’est que ces outils prennent de l’espace pour rien. En effet, il n’y a pas besoin de NodeJS et de npm pour exposer un front via un webserver. Il n’y a pas non plus besoin des librairies téléchargées par npm pour construire le front.

Une solution serait de construire l’application en dehors de notre Dockerfile et d’utiliser COPY pour récupérer le dossier dist. Mais cela va à l’encontre de ce qu’on voulait en introduction :

Nous appellerons optimisé une image de petite taille, qui se téléchargera rapidement, et qui se construira et lancera facilement, sans avoir à installer de dépendance ni aucune manipulation autre qu’un docker build et docker run.

Nous allons faire un Dockerfile multistage. C’est à dire que nous aurons plusieurs instructions FROM qui représenteront chacun un stage. Nous utiliserons le premier stage comme conteneur de build.

# premier stage basé sur une image node qui contient tout ce qu'il faut pour pour construire notre front
FROM node:8.4.0-wheezy AS builder

WORKDIR /app
# on récupère tout le projet
COPY . ./

# on construit l'application comme avant
RUN npm install
RUN npm run build

# deuxième stage, basé sur notre nginx
FROM nginx:1.17.5-alpine

# port à exposer pour accéder à l'application
EXPOSE 80

# on récupère le résultat de notre conteneur de build
COPY --from=builder /app/dist/ /usr/share/nginx/html

L’avantage de cette méthode, c’est que l’image docker produite ne contiendra que ce qui a été exécuté dans le dernier stage. Dans ce cas là, le filesystem de l’image nginx alpine et le dossier dist récupéré du stage builder. Si on regarde la taille finale de notre image :

docker build -t hello-step3 .
...
docker images hello-step3
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
hello-step3         latest              92e06636ca2c        5 minutes ago       23.8MB

23.8Mo ! Pas mal, on a divisé par 10 la taille de notre image de la version 2. Quoi ? Quel est le problème ? 8.34Mo + 2.4Mo ne fait pas 23.8Mo ? Ah mais oui c’est normal, sur le dockerhub ils indiquent la taille des images dans un format compressé. La preuve :

docker images nginx:1.17.5-alpine
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
nginx               1.17.5-alpine       b6753551581f        5 minutes ago       21.4MB

21.4Mo + 2.4Mo = 23.8Mo. Le compte est bon. On a la preuve qu’on a construit une image clean avec le strict minimum pour faire tourner notre frontend. Enfin… Est-ce qu’on a vraiment besoin d’un système d’exploitation pour ça ? Est-ce qu’Alpine ne serait pas de trop ?

Version 4 : Utiliser un binaire statique

Tout d’abord, petit rappel de ce qu’est un conteneur :

Un conteneur est un process démon qui va être isolé des autres. On pourrait faire un amalgame avec les VMs mais une VM est faite pour simuler un OS au complet et y faire tourner une multitude de process comme si vous étiez sur votre ordinateur directement. Non, un conteneur est un process, c’est tout. Il partagera le même système d’exploitation que votre machine hébergeante.

Comment optimiser les images Docker pour la production ?

conteneur vs machine virtuelle

Si un conteneur c’est juste un seul process, tout ce qui sera exécuté au final c’est un serveur web. À aucun moment on exécutera un système d’exploitation. Finalement, Alpine n’est pas là pour être l’OS de notre process nginx, mais pour apporter dans le filesystem les dépendances dont nginx a besoin comme la libc.

Bon c’est bien ce que je pensais, l’intégralité d’Alpine n’est pas nécessaire. Ce qu’il faut c’est que je trouve une image docker d’un webserver compilé statiquement et basé sur un filesystem vide (from scratch).

Après une brève recherche, il n’existe pas dans docker hub une telle image à disposition. J’ai donc créé ma propre image que j’ai nommé franckcussac/rust-webserver:static basée sur le gist simple HTTP example for rust. Elle va me servir de base pour l’image de mon frontend.

FROM node:8.4.0 AS builder

WORKDIR /app
COPY . ./

RUN npm install
RUN npm run build

# on récupère l'image construite à partir du gist
FROM franckcussac/rust-webserver:static

# port à exposer pour accéder à l'application
EXPOSE 80

# on récupère le résultat de notre conteneur de build
COPY --from=builder /app/dist/ /public/

On construit et on regarde la taille de notre image :

docker build -t hello-step4 .
...
docker images hello-step4
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
hello-step4         latest              e5ae3d50bdf1        9 minutes ago       5.24MB

Version 5 : MUSL c’est trop lourd

Nous venons de créer une image docker pour notre frontend avec dedans :

  • un binaire de webserver
  • le site internet

Pour une taille totale de 5.24Mo, dont 2.4Mo incompressible. Ça nous fait 2.8Mo seulement dédié au webserver… On se dit qu’on peut pas aller plus loin ? Hé, en vrai regardez le code du webserver, il fait 100 lignes. 2.8Mo de binaire pour un code de 100 lignes ? C’est beaucoup non ? Ah oui bien sur, il faut inclure MUSL dans le binaire. Mais MUSL c’est plein de choses du standard C dont je n’ai pas besoin. Est-il possible de se passer de MUSL ou toute librairie faisant partie du standard d’un langage ?

Oui, pour ça il nous faudrait un webserver en assembleur ! Ça tombe bien il y en a plein déjà existant, et certains sont même sur le dockerhub tout prêt. Voyons voir la taille d’une de ces images.

Comment optimiser les images Docker pour la production ?

3ko !! Alors là même si c’est la taille compressée c’est vraiment léger. Notre site web faisant 2.4Mo, 3ko c’est totalement négligeable. Et comme l’image docker existe déjà, j’ai juste à l’utiliser en base et à déposer mon site internet dans le bon dossier.

FROM node:8.4.0-wheezy AS builder

WORKDIR /app
COPY . ./

RUN npm install
RUN npm run build

# l'image docker du webserver en assembleur
FROM jtyr/asmttpd:0.4.4-1 

COPY --from=builder /app/dist/ /dist
EXPOSE 80

# la commande pour lancer le webserver
CMD ["/asmttpd", "/dist", "80"]

On build et on regarde la taille de l’image finale :

docker build -t hello-step5 .
...
docker images hello-step5
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
hello-step5         latest              1807f0568b7d        12 seconds ago      2.36MB

2.36Mo, c’est la taille de notre frontend. Mission accomplie, on ne pourra plus réduire d’avantage.

Ce qu’il faut retenir de tout ça

En 5 étapes, nous sommes passés d’une image docker de plus de 600Mo à 2.36Mo. C’est 300 fois plus petit qu’au départ. En revanche, on notera quelques petits défauts à partir de la version 4.

Déjà, la version 4 a été un peu plus complexe à réaliser puisse qu’il a fallut d’abord dockeriser un projet ne nous appartenant pas pour ensuite l’utiliser comme base sur notre projet. Ensuite, dans les version 4 et 5 nous utilisons des images FROM scratch. C’est-à-dire qu’il n’y a rien dessus. Même pas de /bin/sh comme sur Alpine pour debugger notre application en cas de problème. De plus, la version 5 utilise un serveur web en assembleur, qui est une très mauvaise idée pour aller en production niveau sécurité.

Donc si entre la version 3 et 5 il y a quand même 19Mo de différence (soit ~600% plus petit), cela vaut-il vraiment toute la peine que l’on se rajoute ? Pour la version 4 cela peut dépendre de votre contexte, mais je vous déconseille fortement d’aller chercher des logiciels en assembleur pour construire votre application comme fait en version 5.

 vous pouvez aussi lire d’autre sujet similaire #Docker et n’hésitez pas à nous dire ce que vous en pensez dans les commentaires ci-dessous.

About Oussama ABAI

One thought on “Comment optimiser les images Docker pour la production ?

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Résoudre : *
12 + 11 =