Bonnes pratiques pour sécuriser et optimiser la création d’images de conteneurs (Docker ou autres container runtimes)

Rédigé par Katia HIMEUR le 2/10/2024
Temps de lecture: 11 minutes

1. Introduction

La conteneurisation permet d’encapsuler des applications et leurs dépendances dans des unités isolées appelées conteneurs, permettant une exécution homogène dans différents environnements. De plus en plus d’applications tournent dans des environnements conteneurisés. La popularité croissante des conteneurs a également augmenté les risques de sécurité, avec des attaques exploitant des images non sécurisées ou des vulnérabilités dans les dépendances.

Avec l’essor des architectures cloud-native et la volonté d’optimiser les ressources dans les environnements cloud ou on-premise, la conteneurisation est devenue un incontournable pour le déploiement d’applications. Dans le même temps, c’est aussi un véritable challenge pour les équipes, notamment de développement, auxquels nous demandons, en plus de développer, d’intégrer dans leurs pratiques la conteneurisation tout en respectant les bonnes pratiques.

Dans cet article, je vais partager avec vous, les recommandations que nous appliquons chez Cockpit io. Celles que nous avons l’habitude de dispenser au cours de nos missions ou de nos ateliers pour construire des images sécurisées et optimisées pour la production. Avant d’aller plus loin, nous allons commencer par un petit rappel. Docker n’est pas la seule solution pour construire des images de conteneurs, même si pendant longtemps, c’était le standard dans l’industrie. Les recommandations présentées ici s’appliquent à l’ensemble des container runtimes, dès lors que la construction de l’image est réalisée à partir d’un Dockerfile.

2. Les bonnes pratiques pour construire des images de conteneurs

2.1. Choisissez vos images de base

Les images provenant de sources non vérifiées peuvent contenir des vulnérabilités ou des malwares intégrés. Il est essentiel de se fier à des images de confiance pour assurer un niveau élevé de sécurité et de fiabilité. En choisissant des images de confiance, vous vous assurez qu’elles seront maintenues à jour par des sources fiables. Évitez les images provenant de comptes obscurs et sélectionnez vos images avec soin. Si votre source est le hub officiel Docker, vous avez la possibilité de filtrer les images de confiance qui sont partagées en trois catégories :

  • les images officielles Docker
  • les images d’éditeurs vérifiés par Docker
  • et les images open source sponsorisées par Docker.

D’autres images de confiance sont aussi disponibles publiquement en dehors du hub officiel Docker. Pour les trouver, référez-vous aux documentations des éditeurs d’applications que vous souhaitez déployer.

Si vous ne trouvez pas une image de conteneurs de confiance correspondant à votre besoin, envisagez de la construire vous-même en démarrant d’une image de confiance.

2.2. Optimisez la construction de vos images grâce au cache

Un Dockerfile est constitué d’une ou plusieurs instructions qui décrivent les étapes de construction d’une image. Chaque instruction représente un layer (couche) de l’image finale. Chaque image est composée de plusieurs couches superposées, chaque nouvelle couche représentant une modification par rapport à la précédente. Si aucune modification n’est détectée, les couches ne sont pas reconstruites. Si une modification survient au niveau d’une couche, cette couche et toutes celles qui se suivent sont invalidées et devront être reconstruites.

Illustration du fonctionnement du cache Docker
Illustration du fonctionnement du cache das les images de conteneurs

La rédaction du Dockerfile et l’ordre dans lequel nous positionnons les instructions qu’il contient ont un impact sur le résultat final de l’image à cause du fonctionnement des couches et du cache.

Prenons l’exemple suivant pour illustrer notre propos : nous souhaitons débuter d’une image de base Ubuntu pour y installer Curl. Si nous séparons les instructions apt-get update et apt-get install curl, comme aucune modification ne sera détectée dans l’instruction update, tant qu’une couche correspondant à cette instruction existe dans notre environnement de build, elle ne sera jamais reconstruite. Nous prenons le risque d’installer des versions obsolètes des packages. Il est donc recommandé de combiner ces instructions dans une seule commande RUN pour garantir que la liste des paquets soit toujours à jour.

Pensez également à utiliser des caches partagés dans vos pipelines CI/CD pour tirer parti des couches déjà construites et ainsi réduire les temps de construction.

Certaines instructions ne nécessitent pas de rafraichissement ou d’invalidation. C’est le cas des instructions comme ENV, WORKDIR ou CMD. Pour accélérer la construction des images, il est aussi recommandé de positionner toutes ces instructions en début de Dockerfile (ENV, WORKDIR, CMD, EXPOSE…). Ainsi, sauf changement, les couches correspondantes ne seront pas invalidées.

2.3. Optimisez la taille des images

Pour optimiser la taille des images de conteneurs, il faut privilégier les images Slim ou Alpine selon les besoins.

Assurez-vous de n’installer que les librairies et packages réellement utiles pour le bon fonctionnement de l’image.

Différence de taille d’images Python et Python Slim
Différence de taille d'images Python et Python Slim

Utilisez-les multistage builds pour réduire la taille de vos images en isolant les étapes de compilation des dépendances de l’image finale destinée à la production. Ainsi, le Dockerfile est découpé en plusieurs étapes, avec une étape finale dont le résultat est l’application uniquement et ses dépendances pour s’exécuter correctement.

 1# Première étape: Compilation
 2FROM golang:1.23 AS builder
 3WORKDIR /usr/src/app
 4COPY . .
 5RUN go build -o myapp
 6
 7# Deuxième étape: Image finale
 8FROM alpine:3.20
 9WORKDIR /usr/src/app
10CMD ["./myapp"]
11COPY --from=builder /usr/src/app/myapp .

Exemple d’un Dockerfile avec le multistage build.

Si vous installez des packages avec apt-get, ajoutez le paramètre --no-install-recommends et nettoyez le cache avec rm -rf /var/lib/apt/lists/* après installation. Cela permet de ne pas inclure les fichiers de cache dans l’image finale. Dans le cas d’images Alpine, rm -rf /var/cache/apk/*.

Selon l’image que vous construisez, vous pouvez éviter de générer du cache non nécessaire et supprimer le cache généré au moment du build (pip install –no-cache-dir, yarn cache clean…) . Adaptez cette recommandation à votre projet.

1FROM ubuntu:24.10
2ENTRYPOINT ["/entrypoint.sh"]
3RUN apt-get update && \
4    apt-get install --no-install-recommends unzip=6.0-28ubuntu5 -y && \
5    apt-get clean && rm -rf /var/lib/apt/lists/*

Exemple du nettoyage du cache et installation uniquement du package voulu.

Dans le point précédant, nous avons évoqué la notion de couches (layers) d’une image de conteneurs. Chaque couche augmente la taille finale de l’image, il est recommandé de réduire le nombre de couches en réduisant le nombre d’instructions. Dans la mesure du possible, regroupez les commandes qui peuvent l’être dans une seule instruction RUN. Si vous installez des packages avec la commande apt-get ou apk add, supprimer le cache à la fin de cette instruction. Evitez de créer une couche uniquement pour cette commande. Cela ne fera qu’augmenter la taille de l’image au lieu de la réduire.

Cependant, parfois, il m’arrive volontairement de contrevenir à cette règle. Dans certains cas précis, certaines commandes peuvent prendre du temps à s’exécuter. Dans ces cas-là, je préfère avoir une instruction RUN en plus pour l’exécuter afin de ne pas prendre le risque d’invalider cette couche et de devoir la reconstruire à chaque fois. Faites un arbitrage selon vos besoins en choisissant entre réduire la taille de l’image finale ou réduire son temps de construction.

Pour optimiser la taille des images de conteneurs, il est important de comprendre la notion de build context. Le build context est l’ensemble des fichiers et répertoires qui peuvent être utilisés au moment de la construction de notre image de conteneur. Sauf indication contraire, l’ensemble des fichiers présents dans le contexte est transmis au moment de la construction de l’image. Ceci inclut les fichiers de cache, fichiers temporaires ou tout autre fichier se trouvant dans le contexte à ce moment. Une bonne pratique est d’exclure des fichiers du contexte en les spécifiant dans un fichier .dockerignore. Posez-vous les questions suivantes : est-ce nécessaire d’inclure le répértoire .git/ dans mon image ? Est-ce pertinent d’y inclure les fichiers de tests ? ou bien les fichiers de documentation *.md ? Si la réponse est non, ajoutez-les dans le fichier .dockerignore.

Avoir des images légères offre plusieurs avantages : cela réduit la surface d’attaque possible, les téléchargements sur les environnements de déploiement sont plus rapides et les ressources occupées par les images sont réduites.

2.4. Tenez à jour vos images et leurs dépendances

Tout comme pour les environnements non conteneurisés, les images de conteneurs et leurs dépendances doivent être mises à jour dès que possible. Que cela soit les images de base ou les composants installés dedans, il faut suivre les mises à jour pour se prémunir des bugs et d’éventuelles vulnérabilités.

Des outils existent pour détecter les vulnérabilités. Ces outils sont parfois intégrés aux registres d’images de conteneurs ou peuvent être intégrés dans les pipelines de CI/CD. C’est le cas de Trivy par exemple.

Une image sans vulnérabilité détectée aujourd’hui ne veut pas dire qu’elle le restera toujours. De nouvelles vulnérabilités peuvent être découvertes. Veillez à scanner régulièrement vos images et à appliquer les mises à jour pour réduire les risques.

Assurez-vous de maitriser vos dépendances. La sécurité passe aussi par le maintien à jour de vos composants. Des outils comme Renovate ou Dependabot facilitent ce travail de recherche de nouvelles versions.

2.5. Une image = un service

Il peut être tentant de créer des images avec plusieurs services déployés à l’intérieur. Ceci a un désavantage de complexifier le maintien de ses images et d’augmenter les risques d’erreur. Le déploiement d’une image par service facilite la scalabilité et la maintenance. En cas de mise à jour ou de défaillance d’un service, cela vous permet de redéployer uniquement l’image concernée sans affecter les autres services.

2.6. Fixez les versions de vos images et vos dépendances.

Il est crucial d’éviter d’utiliser des images taguées ‘latest’. Cela empêche un contrôle précis des versions déployées et expose à des risques liés à des mises à jour non souhaitées.

N’utilisez pas d’images latest comme image de base et ne taguez pas vos propres images en latest. Vous pourrez par exemple, si vous le souhaitez, utiliser la méthode de gestion sémantique de version pour correctement choisir vos tags.

Cette recommandation concerne également les dépendances que vous installez dans vos images : assurez-vous de toujours fixer vos versions pour éviter des changements indésirables et non maitrisés.

Exemple de bonnes pratiques
Exemple de bonnes pratiques

2.7. Faites le bon choix entre les images Alpine ou Slim

Pour réduire la taille des images, il est courant d’opter pour des images Alpine ou Slim. Ces images sont très populaires dans le monde des conteneurs, car elles sont réputées légères. Cependant, attention, certaines applications peuvent rencontrer des problèmes avec les images Alpine, notamment en raison de l’utilisation de la bibliothèque C musl qui remplace glibc dans les images Alpine.

Dans la majorité des cas, l’utilisation d’images Alpine n’a pas d’impact sur le bon fonctionnement de l’image, mais parfois cela peut poser un problème. Avant d’opter pour une image basée sur Alpine, assurez-vous que vos dépendances critiques sont compatibles avec musl. Sinon, envisagez une image basée sur Debian Slim, qui offre une bonne alternative entre légèreté et compatibilité.

2.8. Signez vos images

La signature d’images de conteneurs est une pratique qui permet de garantir l’intégrité et l’authenticité des images déployées en s’assurant de la légitimité de la source de provenance de ladite image. À l’exécution de l’image, la signature cryptographique peut être vérifiée pour garantir que l’image n’a pas été altérée et qu’elle provient bien d’une source autorisée.

Si une modification non désirée intervient au niveau de l’image, pour introduire du code malveillant par exemple, la signature ne correspondra plus et révélera alors des modifications non validées.

La signature d’images de conteneurs est aussi un moyen de répondre à des exigences de mises en conformité, des exigences réglementaires ou un besoin de sécuriser sa supply chain logicielle.

Pour signer des images, vous pouvez utiliser des outils comme Cosign ou Docker Content Trust (DCT)

2.9. Minimiser les droits des utilisateurs dans les conteneurs

Comme pour les environnements traditionnels, le principe du ‘least privilege’ s’applique également aux conteneurs. Un conteneur qui s’exécute avec des droits root, peut être un véritable problème de sécurité. Si un attaquant réussit à compromettre l’intégrité d’un conteneur, il pourra y déployer et exécuter du code malveillant, accéder et modifier des fichiers critiques ou bien faire une escalade de privilèges et compromettre tout le système hôte.

Certains éditeurs fournissent des images dites rootless. C’est le cas de Nginx avec son image : nginxinc/nginx-unprivileged

2.10. Utiliser un registre de conteneurs comme Proxy cache

Il peut être utile d’utiliser des registres de conteneurs comme proxy cache pour stocker des images et éviter ainsi d’aller les chercher sur les hubs publics. Cette stratégie permet, par exemple, de s’assurer que seules les images autorisées peuvent être utilisé ou bien de limiter l’impact d’une indisponibilité du hub public. Cela peut aussi répondre à des besoins de sécurité ‘d’environnements air gap.

Des solutions comme Harbor ou Artifactory permettent d’héberger un registre privé pour stocker des images locales et servir de proxy cache afin de limiter la dépendance aux registres publics.

2.11. Si plusieurs images partagent la même base, créer une image commune

Dans certains projets, nous nous retrouvons avec des fichiers Dockerfile qui se ressemblent et dont une base est commune. La création d’une image de base commune permet non seulement de faciliter la maintenance, mais aussi de réduire le temps de construction et d’améliorer la sécurité, en contrôlant mieux les mises à jour et en évitant de multiplier les bases d’images similaires.

Conclusion

Alors que la conteneurisation continue de s’imposer, il est important d’adopter une approche proactive de la sécurité et d’intégrer les meilleures pratiques dans chaque étape du cycle de développement, de la construction à la production.

En adoptant ces bonnes pratiques, vous améliorez considérablement la sécurité, la fiabilité et l’efficacité de vos conteneurs en production. À mesure que l’utilisation des conteneurs continue de croître, il est essentiel de rester vigilant et de mettre en place des processus automatisés pour garantir la sécurité continue de votre infrastructure.

Des outils vous permettent d’automatiser le scan de vos Dockerfile, c’est le cas de hadolint ou de SonarLint.