Rechercher
  • Jacky Montiel

Conteneurs légers pourquoi et comment ?

Dernière mise à jour : 21 mai 2020

Une des bonnes pratiques en construction de containers consiste à créer des images les plus petites possibles. Dans ce post nous revenons sur cette pratique et sa mise en oeuvre. Nous nous appuierons sur la technologie de référence : Docker.


Les images légères en bref


La composition d'une image Docker est définie dans son fichier Dockerfile. Le principe de construction des images est l'empilement de couches (layers) pour enrichir une image de départ dite couche (ou image) de base. C'est la commande FROM en début du Dockerfile qui précise cette image de base.


Le poids de l'image finale résulte du cumul des différentes couches. Il dépend donc en grande partie de l'image de base, mais aussi des packages installés et des fichiers de build ajoutés dans les couches suivantes.


L'image de base est une forme de distribution Linux packagée en conteneur. Elle contient les outils nécessaires pour construire les couches supérieures à l'aide de l'instruction RUN du Dockerfile. C'est là que se différencient les images dites légères (slim) : en partant d'une image de base minimale et en y ajoutant le strict nécessaire à l’application, et en faisant le nettoyage des fichiers de travail inutiles en fin de chaque couche.


Pour l'image de base, à l’extrême limite la commande 'FROM scratch' permet de partir d'une image vide pour y ajouter le simple nécessaire. Exemple : ici un simple exécutable hello ne requérant aucune lib dynamique,

FROM scratch
COPY hello /
CMD ["/hello"]

Les images de base lègères les plus courantes sont : Alpine, Busybox, Minideb, la famille Distroless. Voici un tableau de leur tailles :

REPOSITORY : TAG          ->    SIZE
bitnami/minideb:latest    -> 67.5MB
centos:centos7            -> 203MB
alpine:latest             -> 5.61MB
openjdk:8                 -> 510MB
debian:stretch-slim       -> 55.3MB
debian:stretch            -> 101MB
phusion/baseimage:latest  -> 209MB
gcr.io/distroless/java:8  -> 125MB
gcr.io/distroless/java-debian10:latest -> 198MB

A titre de comparaison on peut voir que gcr.io/distroless/java:8 a une taille de 125 MB contre 510 MB pour openjdk:8.


Une image de base est caractérisée par :

  • une base de commandes et de lib réduite (contient notamment une libc utile à la plupart des autres commandes)

  • (optionnellement) : un système d'installation de package

Le système de packages peut-être attaché à des repositories de distribution classique (exemple de Debian-slim ou Minideb) ou spécifiques à la distribution (cas d'Alpine). Le poids de la libc est aussi déterminant : on trouvera en Ref 4 un tableau comparatif des tailles de différentes libc.


Alpine fournit un environnement minimaliste avec comme particularités :

  • un ensemble variantes de libc, dont une libc optimisée

  • un gestionnaire de packages (apk) et de repositories de packages (notamment les compilateurs et outils de build classique)

Voici un exemple d'utilisation d'Alpine pour une application SpringBoot tel que fournie par SpringSource :

FROM openjdk:8-jdk-alpine
VOLUME /tmp
COPY target/*.jar app.jar
ENTRYPOINT ["java","-jar","/app.jar"]

# Note : l'instruction 'VOLUME /tmp' est un artifice technique de performance 
# pour conserver le serveur d'application (Tomcat en général) dans /tmp sur du host

Le projet Distroless offre une approche originale en fournissant des images de base qui embarquent les dépendances liées à un type d'application (Java, Python, Node, .Net, C). Dans ce cas aucun gestionnaire de package n'est nécessaire (cf Ref 0).


Voici un exemple Distroless d'application Java :

# Dockerfile classique
FROM openjdk:8
COPY dockertest.jar .
CMD ["java", "-jar", "dockertest.jar"]

# Dockerfile Distroless
FROM gcr.io/distroless/java:8
COPY dockertest.jar .
CMD ["dockertest.jar"]

On voit que l'image de base distroless a une taille de 125 MB contre 510 MB pour OpenJDK.


Enfin, comme il a été précisé ci-dessus, la taille de l'image finale ne dépend pas que de l'image de base mais aussi des packages qu'on peut y ajouter et des fichiers de travail qui peuvent être laissés après une compilation. Il est donc recommandé de détruire les répertoires de travail dans la même commande RUN.


Exemple sur une souche Fedora :

RUN dnf install -y nginx \
  	&& dnf clean all \
  	&& rm -rf /var/cache/yum


Pourquoi utiliser des images légères ?


Il y a essentiellement 2 avantages à utiliser des images légères : les temps de chargements et la sécurité.


Temps de transfert des images

On peut distinguer 2 types d'opérations : les push et les pull d'images vers/depuis un registry de containers,

  • Push : le temps de transfert vers le registry de stockage (DTR, ECR, ACR, Nexus, ...). Dans ce temps, on doit aussi inclure, lorsque c'est le cas, le temps du scan de sécurité, qui croit avec la taille de l'image

  • Pull : le temps de chargement du registry vers l'environnement d'exécution Là 2 cas possibles de pull,

  1. soit depuis le registry de stockage, plus ou moins proche sur le réseau,

  2. soit depuis un cache local de runtime sur le noeud d'exécution, si l'image y est disponible


Dans un pipeline DevOps la taille de l'image va avoir un impact croissant sur le temps de Push avec le nombre de builds d'images et de déploiements réalisés.


Concernant le temps de Pull, si on prend le cas Kubernetes, le démarrrage/redémarrage de Pod doit être le plus rapide possible. C'est le runtime docker installé sur chaque noeud qui réalise le pull et stocke les images dans son registry local de noeud. Lorsqu'on travaille en mode développement en utilisant des images ':latest', on configure Kubernetes en imagePullPolicy à Always pour éviter le cache et utiliser systématiquement la dernière version produite.


En production, on va plutôt utiliser des images images taggées et donc une imagePullPolicy à IfNotPresent pour profiter du cache. Le poids d'une image peut donc avoir une influence en production lorsque l'image est absente du cache. Ceci se produit seulement au déploiement d'une nouvelle version ou encore au démarrage/redémarrage d'un pod sur un noeud où l'image n'est pas encore dans le cache de ce noeud.


On peut donc en conclure qu'en général l'impact de poids de l'image en environnement de production ne sera pas vraiment significatif, sauf dans les cas de déploiements très fréquents (plusieurs dans la journée). Par contre en environnement de développement où les déploiements sont en général très fréquents, l'impact peut-être plus significatif.


Sécurité

L'usage des containers accélère et simplifie grandement le cycle de développement des logiciels, mais c'est une source de vulnérabilités. Comme un OS dont les composants sous soumis aux patchs de sécurité, l'image est porteuse de vulnérabilités. Des scanners d'images permettent de détecter les CVE (Common Vulnerabilities and Exposures) et d'évaluer son niveau de sécurité. Bien évidemment, les vulnérabilités de l'image ne sont qu'un aspect de la sécurité dans le pipeline DevOps et la sécurité de l'infrastructure d'exploitation.

Voici un tableau des vulnérabilités établi par Snyk sur quelques images de base :



La première règle de sécurité consiste donc à limiter la surface d'attaque en utilisant des images les plus légères possibles, c'est-à-dire en installant le minimum de dépendances.

Complémentairement, il est recommandé de définir une politique d'actualisation des images de base pour intégrer les patchs correctifs des dépendances et de revérifier les images générées avec leurs dépendances.


Comment construire des images légères?


Voici 3 techniques de base (pour des techniques complémentaires cf Ref3).


Technique 1 : utiliser une image de base légère

Cette technique n'a rien de particulier : Il suffit d'utiliser une image de base légère parmi celles citées ci-dessus, selon ses besoins. en prenant soin de minimiser les installations et d'opérer un clean comme indiqué ci-dessus.

Précaution pour le clean : utiliser le minimum de RUN et surtout faire le clean dans le même RUN que les opérations de build (chaque RUN va créer une couche non modifiable dans l'Union FS de stockage des images).

Cette technique est cependant moins efficace que la technique du build multi-stages dans la plupart des cas.


Technique 2 : utiliser un build d'image multi-stages

Lorsqu'on utilise des langages compilés (Java, Go, C, C++, ...), la compilation dans l'image du conteneur final nécessite d'alourdir son image avec les outils de compilation, les lib nécessaires etc...


Pour optimiser cela, on utilise la fonctionnalité de build multi-stages de Docker introduite en v 17.05 (voir ici). Cette technique consiste à déclarer dans le Dockerfile 2 sections 'FROM ....', correspondant à 2 phases de build. Une première phase avec une image de base qui sert à produire le code compilé, puis une deuxième phase qui va utiliser le produit de la première pour produire le conteneur final. La 2ème section va en général utiliser une image de base de type Alpine ou Distroless.


Voici un exemple utilisant l'approche Distroless, pour produire une image d'application Java :

# 1re image : on compile le code dans main.jar
FROM openjdk:11-jdk-slim AS build-env
ADD . /app/examples
WORKDIR /app
RUN javac examples/*.java
RUN jar cfe main.jar examples.HelloJava examples/*.class 

# on utilise le JRE Java 11 pour exécuter le main.jar en conteneur
FROM gcr.io/distroless/java:11
COPY --from=build-env /app /app
WORKDIR /app
CMD ["main.jar"]

De cette manière seule le JDK 11 et l'application sont dans le conteneur final.


Technique 3 : utiliser l'aminciceur Docker-slim

Cette technique est propre à Docker et utilise l'outil docker-slim, sous licence OpenSource.

Docker-slim est un outil CLI qui analyse et élimine les dépendances inutiles pour obtenir une taille réduite jusqu’à 30x. Il peut-être exécuté en mode interactif ou automatique.


Typiquement (exemple fournit par Docker-slim) appliqué à une application Node.js buildée sur une image de base ubuntu on obtient un gain de 30x:

$ head -n 1 Dockerfile
FROM ubuntu:14.04
 
$ docker images my/sample-node-app
REPOSITORY                TAG                 IMAGE ID            CREATED             SIZE
my/sample-node-app        latest              31be09316a19        4 minutes ago      432MB
 
$ docker-slim build --http-probe my/sample-node-app
docker-slim[build]: state=started
docker-slim[build]: info=params target=my/sample-node-app continue.mode=enter
docker-slim[build]: state=inspecting.image
docker-slim[build]: state=inspecting.container
docker-slim[build]: info=container ... target.port.list=[32908] target.port.info=[8000/tcp => 0.0.0.0:32908]
docker-slim[build]: info=prompt message='press <enter> when you are done using the container'
docker-slim[build]: state=http.probe.starting
docker-slim[build]: info=http.probe.call status=200 method=GET target=http://127.0.0.1:32908/ attempt=1 error=none
docker-slim[build]: state=http.probe.done
docker-slim[build]: state=processing
docker-slim[build]: state=building message='building minified image'
docker-slim[build]: state=completed
docker-slim[build]: info=results status='MINIFIED BY 30.88X [432330078 (432 MB) => 14002579 (14 MB)]'
docker-slim[build]: info=results image.name=my/sample-node-app.slim image.size='14 MB' data=true
 
$ docker images my/sample-node-app.slim
REPOSITORY                TAG                 IMAGE ID            CREATED             SIZE
my/sample-node-app.slim   latest              b72b685be7fe        1 minute ago        14 MB

Cet outil peut-être intégré dans le pipeline DevOps en mode automatique, mais il augmente le temps de Push à chaque reconstruction de l'image. La perte de temps induite n'est donc pas vraiment profitable.


Conclusion


Le réflexe naturel est d'utiliser des images de base riches (debian, centos, ubuntu) pour disposer d'un environnement similaire à une distribution OS classique, que ce soit pour le développement ou l'exploitation.


On peut estimer selon ce qui a été vu ci-dessus, qu'en environnement dev c'est l'aspect temps de transfert qui est le plus impacté par la taille des images, alors qu'en environnement prod c'est la sécurité qui est la plus impactée.


La définition et la mise en oeuvre d'une stratégie d'images légères, même si elle demande un petit travail au départ pour choisir ses images de base et construire des Dockerfile multi-stages, va réduire les risques de lenteurs dans les pipelines DevOps pour le dev et limiter la surface d'attaque des conteneurs pour la prod. Il est donc fortement conseillé de l'appliquer systématiquement dans les projets.


Liens utiles :

118 vues0 commentaire

Posts récents

Voir tout