Workflows pilotés par les développeurs : analyse, priorisation et correction des images Dockerfile
26 mars 2021
0 minutes de lectureLorsqu’ils déploient des applications dans des conteneurs, les développeurs doivent désormais assumer des responsabilités liées à la sécurité au niveau du système d’exploitation. Il s’agit souvent de sujets qui leur sont peu familiers et qui, dans bien des cas, relevaient auparavant des équipes d’exploitation et de sécurité. Ce nouveau domaine peut sembler intimidant, mais vous pouvez intégrer différents outils et pratiques à votre workflow pour détecter et corriger les problèmes avant leur arrivée en production. Dans cet article, vous découvrirez comment détecter, prioriser et corriger les problèmes dans les images de conteneurs de vos applications à l’aide de différents outils, et comprendrez mieux l’impact de ces corrections au déploiement.
L’application d’exemple utilisée dans cet article est à votre disposition pour suivre les étapes et faire des essais si vous le souhaitez. Elle se trouve sur GitHub à l’adresse https://github.com/snyk-snippets/dev-driven-workflows.
Sommaire
10 façons d’optimiser et de sécuriser votre application conteneurisée
Des images de conteneurs bien conçues sont essentielles
La base de tout conteneur que vous déployez est largement définie par l’image Docker/OCI sur laquelle il repose. Il est donc important de comprendre ce qu’est une image de conteneur et comment elle fonctionne.
Qu’est-ce qu’une image de conteneur ?
Essentiellement, une image est un ensemble d’archives de systèmes de fichiers et de définitions de métadonnées. Chacune contient une partie du système de fichiers et des paramètres d’environnement qui définissent l’état initial d’un conteneur. Les couches sont empilées logiquement, le contenu de chacune venant s’ajouter à celui de la précédente, parfois appelée sa couche parent. Chaque couche est hachée par une fonction cryptographique afin d’en garantir l’intégrité. L’image en conserve le manifeste, ainsi que son propre hachage, appelé digest d’image.

Lorsqu’un conteneur est créé, le système de fichiers qui lui est présenté résulte de l’union des couches de l’image, à laquelle peut s’ajouter une couche en lecture-écriture. Tous les fichiers écrits par le conteneur le sont dans cette couche en lecture-écriture, car les couches de l’image sont immuables. Les modifications apportées aux fichiers déjà présents dans les couches de l’image commencent par une copie vers la couche en lecture-écriture, où elles sont ensuite effectuées : c’est le comportement copy-on-write (copie à l’écriture).
Qu’est-ce qu’un Dockerfile ?
Un Dockerfile est une simple liste d’instructions qui décrivent ce que doit contenir chaque couche d’une image de conteneur.
Exemple de Dockerfile
Dans l’exemple suivant, vous pouvez voir les couches se construire à partir de l’image de base node:14.1.0. Chaque couche successive contient les métadonnées ou les modifications du système de fichiers résultant des instructions Dockerfile de chaque ligne. Cet exemple se trouve dans le dépôt GitHub mentionné plus haut, sous le nom Dockerfile-initial.
Si nous exécutons une commande docker build à partir de cet exemple de Dockerfile, elle crée une couche contenant le répertoire /usr/src/goof, puis une autre couche contenant le nouveau répertoire /tmp/extracted_files.
La couche suivante copie le contenu du répertoire courant de la machine de build — indiqué par le . dans la commande de build — y compris ses sous-répertoires, dans le répertoire /usr/src/goof de la nouvelle couche.
La construction se poursuit avec l’ajout de nouvelles couches jusqu’à la fin du fichier.
Chaque couche d’une image s’appuie sur sa couche parent, mais une fois construite, elle est immuable. Même si nous ajoutons une ligne pour supprimer un fichier d’une étape précédente, son contenu reste présent dans la hiérarchie ; la suppression n’est qu’une différence apportée par la nouvelle couche. Le principe est similaire à celui des dépôts de code source comme git : un commit qui supprime un fichier ne l’efface pas des commits précédents. Ainsi, si un fichier de 1 Mo est ajouté à une couche, puis supprimé dans une couche ultérieure, l’image contiendra toujours ces 1 Mo, même si le fichier est invisible pour le conteneur.
Instructions RUN composées
Il est courant de voir des instructions composées dont la dernière étape consiste à nettoyer afin d’éviter que des fichiers temporaires restent dans le système de fichiers de la couche. Notre Dockerfile n’en contient pas, mais voici un exemple représentatif d’une pratique courante lors de l’installation de paquets au niveau du système d’exploitation avec apt, yum, etc.
On voit ici une version précise de curl installée sur une image Ubuntu, en veillant à ne pas stocker les caches apt dans la couche.
Notre exemple de Dockerfile représente les étapes impératives qui servaient à construire et exécuter cette application avant le passage aux conteneurs. Il n’est pas très différent de ce qu’on peut trouver dans des applications héritées simplement transférées dans des conteneurs. Il est mûr pour l’optimisation et le renforcement de la sécurité. Voyons cela de plus près.
En plus des optimisations nécessaires, ce Dockerfile contient une erreur qui fait échouer la commande docker build. Examinons le problème et voyons comment l’automatisation peut nous aider à le corriger.
10 façons d’optimiser et de sécuriser votre application conteneurisée
1. Utilisez un linter pour Dockerfile
Une première étape courante pour améliorer nos Dockerfiles consiste à utiliser un linter. Les linters analysent statiquement le contenu d’un fichier et suggèrent des problèmes que nous pourrions vouloir corriger. Comme indiqué plus haut, un bug dans le Dockerfile existant fait échouer le build.
Hadolint est un linter open source populaire pour les Dockerfiles. Il analyse les étapes et donne des recommandations sur leur structure.
Examinons les problèmes détectés :
Utilisez COPY plutôt que ADD pour les fichiers et les dossiersC’est pertinent pour copier des fichiers locaux qui ne sont pas des archives, comme indiqué dans les bonnes pratiques Dockerfile de Docker. Cela vaut la peine de le corriger, mais ce n’est pas la cause de notre problème de build.
Utilisez « cd … || exit » ou « cd … || return » au cas où cd échouerait.C’est techniquement vrai, mais ce n’est pas vraiment le problème ici. Laissons cela de côté pour l’instant.
Utilisez WORKDIR pour passer à un répertoireAh, voilà la cause ! L’effet de la ligne
RUN cd /usr/src/goofse limite à la couche créée par cette instruction ; en fait, cette ligne ne fait rien pour la construction de l’image. C’est la commande DockerfileWORKDIRqu’il faut utiliser : elle définit explicitement le répertoire de travail courant à partir de ce point, y compris à l’exécution. Son absence explique pourquoi la ligneRUN npm…ne trouve pas le fichierpackage.json.Utilisez la notation JSON pour les arguments de CMD et ENTRYPOINTÉgalement appelée forme « exec », cette notation fait l’objet de différents avis, mais la documentation de Docker la recommande.
Après avoir corrigé tous ces problèmes, notre nouveau Dockerfile — nommé Dockerfile-hadolint-fixes dans le dépôt d’exemple — ne présente plus aucun problème de linting et se construit correctement.
2. Exécutez votre linter comme hook de commit pour éviter l’introduction de problèmes dans vos Dockerfiles
Les outils légers comme hadolint sont parfaits pour les hooks pre-commit et empêchent les problèmes de Dockerfile de s’introduire dans votre base de code. Voici un exemple simple de script git que vous pouvez ajouter à votre dépôt, à l’emplacement suivant : .git/hooks/pre-commit. Si les hooks git sont nouveaux pour vous, consultez la documentation Git-SCM pour en savoir plus.
Voici un exemple de ce hook exécuté sur le Dockerfile d’origine, avant l’application de nos corrections.
Remarque : si vous faites un essai dans notre dépôt d’exemple, veillez à copier Dockerfile-initial vers Dockerfile et à l’indexer dans votre dépôt pour déclencher le problème détecté par le hook.
3. Testez votre image localement, de façon itérative
Nous avons fait le nécessaire pour construire une image, corrigé une erreur de build et rectifié quelques mauvaises pratiques au passage. Il nous faut maintenant vérifier que l’image contient bien tout ce qui est nécessaire à l’exécution de l’application.
L’exemple « goof » est une application simple à deux niveaux : un frontend Node.js qui dépend d’un backend de persistance MongoDB. Pour exécuter cette application localement, nous allons procéder comme suit :
Effectuez une vérification rapide de l’image avec
docker run. (En général, cette étape est répétée au fur et à mesure que vous travaillez sur votre Dockerfile.) L’application échouera, car elle n’a aucune base de données à laquelle se connecter, mais si elle arrive jusque-là, vous savez au moins que le conteneur démarre.Lancez un cluster Kubernetes local pouvant accéder à l’image que vous avez créée. Plusieurs options sont possibles. En voici quelques-unes parmi les plus populaires :
Kubernetes sur Docker DesktopIl suffit de l’activer dans le tableau de bord Docker Desktop et d’attendre son démarrage.
Kubernetes dans Docker (KinD)Consultez le guide de démarrage rapide de KinD, puis pensez à charger votre image dans le cluster. Si vous exécutez également KinD sous Docker Desktop, vous devrez configurer votre cluster avec des mappages d’hôtes correspondant au NodePort de goof-service. Nous avons fourni un fichier
kind-config.yamld’exemple que vous pouvez utiliser dans le dépôt correspondant.MiniKubeConsultez le guide de prise en main de MiniKube, ainsi que la page du manuel sur la mise en cache de votre image dans le cluster.
Un cluster Kubernetes distantTout cluster auquel vous avez accès et vers lequel vous pouvez transférer des images devrait également convenir. Vous devrez peut-être envoyer votre image vers un registre auquel il peut accéder. Si vous ne savez pas comment procéder, renseignez-vous auprès de l’équipe chargée de l’exploitation de votre cluster.
Tests avec Docker
Pendant que vous travaillez sur le Dockerfile, vous devez vérifier que l’image est construite correctement. Pour cela, le plus simple est de lancer un conteneur de temps à autre et d’effectuer quelques vérifications ponctuelles.
Par exemple, si nous voulons exécuter l’image créée précédemment avec le tag goof, nous utiliserons docker run --rm -it -p3001:3001 goof.
Cette commande crée et démarre un conteneur basé sur cette image. Le port 3001 de votre poste de travail est associé au port 3001 du conteneur pour y acheminer le trafic. L’option --rm indique simplement à Docker de supprimer le conteneur après son arrêt, et -it le lance en mode interactif avec un tty, afin que nous puissions voir la sortie et envoyer la commande CTRL-C pour l’arrêter.
Vous devriez voir de nombreux messages pendant que l’application Node.js tente de démarrer, notamment une erreur indiquant qu’elle ne peut pas se connecter à sa base de données. Le conteneur s’arrêtera ensuite et sera supprimé à la fermeture de l’application. Si vous voyez autre chose, par exemple une erreur indiquant que npm est introuvable, cela signifie que l’image elle-même présente des problèmes.
Tests sur Kubernetes en local
Nous déploierons l’application à l’aide des fichiers manifestes qui se trouvent dans le dossier manifests du dépôt d’exemple.
goof-deployment.yaml
Une présentation complète des API Kubernetes dépasse le cadre de cet article, mais, dans les grandes lignes, nous déclarons deux Deployments qui déploieront et géreront des Pods dans lesquels s’exécuteront notre conteneur et sa base de données.
Nous allons également déployer un fichier goof-services.yaml qui expose les pods via les Services Kubernetes, permettant ainsi la découverte de cette instance MongoDB. Si vous exécutez Kubernetes sur votre machine locale avec Docker Desktop, KinD ou MiniKube, vous devrez ajouter imagePullPolicy: Never à la spécification du conteneur goof, comme suit :
Si vous utilisez un cluster distant, cette étape n’est pas nécessaire. Vous devrez toutefois pousser votre image vers un registre et modifier en conséquence les balises image: dans ces fichiers YAML. En cas de questions, adressez-vous aux opérateurs de votre cluster.
En supposant que votre cluster Kubernetes est opérationnel et que votre configuration kubectl est prête, il ne vous reste plus qu’à exécuter kubectl apply -f manifests/, puis à attendre que les pods passent à l’état Running, en vérifiant avec kubectl get pods.
Enfin, testons l’application en ouvrant un navigateur à l’adresse suivante :
Docker Desktop ou KinD : http://localhost
MiniKube : exécutez
minikube service goofet utilisez la première URL qui s’affiche.Autre : si une adresse IP externe apparaît avec
kubectl get service goof, utilisez-la. Sinon, essayez l’adresse IP de l’un des nœuds de votre cluster sur le port 32301. Si cela ne fonctionne pas, demandez de l’aide à l’opérateur de votre cluster.
Vous devriez voir quelque chose comme ceci dans votre navigateur :

N’hésitez pas à ajouter une note TODO et à l’enregistrer en appuyant sur Entrée. Si tout fonctionne, elle devrait apparaître dans une liste sur la page.
Recommencez : entraînez-vous au développement et aux tests en local de façon itérative
Maintenant que tout fonctionne, vous pouvez continuer à développer votre application de façon itérative, reconstruire l’image et la pousser vers votre cluster. Il existe probablement des centaines de façons de procéder, selon le type d’application que vous développez et la distribution Kubernetes que vous utilisez, mais le processus ressemble généralement à ceci :
Vous apportez des modifications au code.
Vous créez une nouvelle image à l’aide de la commande
docker buildet/ou des outils de votre langage.Vous poussez l’image vers le cluster ou le registre (si nécessaire).
Le pod Kubernetes en cours d’exécution est mis à jour pour utiliser la nouvelle image. Pour cela, je supprime généralement le pod en cours d’exécution et laisse le cluster en créer un nouveau à partir de la nouvelle image. Vous pouvez également utiliser
kubectl set image, à condition que la nouvelle image ait un nouveau nom de balise.Vous testez le nouveau pod une fois qu’il est opérationnel.
Renforcement supplémentaire de l’image
À part corriger quelques bugs dans le Dockerfile d’origine, nous n’avons pas vraiment abordé sa sécurité. Maintenant que nous savons exécuter et tester notre application, examinons quelques-uns des problèmes de sécurité les plus intéressants que pose notre image.
4. Ne définissez pas l’utilisateur root par défaut dans votre image
Si vous accédez au conteneur de notre application et vérifiez, vous constaterez que le processus node s’exécute en réalité en tant qu’utilisateur root.
Même si le processus s’exécute dans un conteneur dont l’espace de noms des processus Linux l’isole du reste de l’hôte, il n’est pas recommandé de l’exécuter avec l’UID 0, pour plusieurs raisons. Les problèmes habituels proviennent d’erreurs de configuration au moment du déploiement, par exemple lorsqu’un volume de l’hôte est monté dans le conteneur et expose plus d’informations que prévu. L’utilisateur du conteneur disposera des mêmes privilèges sur ce système de fichiers que l’utilisateur ayant le même UID sur l’hôte. Par exemple, si le répertoire /etc de l’hôte est monté dans le conteneur, un processus exécuté en tant que root dans ce conteneur pourra lire et modifier tous les fichiers du dossier /etc de l’hôte.
Vous vous souvenez que nous avons indiqué que les couches sont immuables et que supprimer un fichier d’une couche ne supprime pas réellement son contenu des couches parentes ? Voici un exemple de la façon dont ce principe peut être exploité : un processus dans un conteneur peut lire les fichiers des couches de l’image de conteneur de l’hôte si quelqu’un a monté le chemin /var/lib/docker dans le conteneur. Toutes ces couches sont accessibles à cet emplacement. Un processus malveillant n’aurait donc qu’à rechercher des logiciels exploitables dans chacune d’elles (ou dans /var/lib/containerd, ou à l’emplacement où votre environnement d’exécution de conteneurs les stocke). Heureusement, les responsables des images node officielles ont déjà créé pour nous un utilisateur node doté de l’UID 1000. Pour l’utiliser, il suffit d’ajouter à notre Dockerfile une ligne contenant USER 1000. Nous utilisons l’UID plutôt que le nom de l’utilisateur, car certains outils, Kubernetes par exemple, ne peuvent pas associer le nom d’utilisateur par défaut de l’image à son UID avant le démarrage du conteneur, et peuvent en avoir besoin pour appliquer des règles concernant les utilisateurs root. Nous verrons un peu plus loin dans cet article comment ces règles sont appliquées.
Si l’image de base que vous avez choisie ne contient pas d’utilisateur précréé, vous devrez également en créer un en ajoutant la ligne RUN requise — par exemple adduser — puis la ligne USER pour passer à cet UID. Veillez à définir la propriété et les permissions des fichiers nécessaires au fonctionnement de votre application avec ce nouvel utilisateur. Il est également courant que les exécutables et autres fichiers appartiennent à root, tout en étant lisibles et exécutables par les utilisateurs. Cela permet d’éviter qu’un processus défaillant ou malveillant puisse les modifier lors de l’exécution.
Après la création de l’image et la mise à jour du pod, la situation s’est nettement améliorée.
5. Appliquez des contrôles sur l’utilisateur root au moment du déploiement
Maintenant que l’image s’exécute avec un utilisateur non-root, assurons-nous qu’elle le reste en modifiant le manifeste de déploiement Kubernetes pour l’imposer. Pour commencer, je vais analyser notre fichier goof-deployment.yaml avec Snyk IaC.
Comme vous pouvez le constater, plusieurs problèmes ont été détectés. Sans surprise, l’un d’eux, de gravité moyenne, indique que le conteneur s’exécute sans contrôle de l’utilisateur root. La correction est simple : ajoutez runAsNonRoot: true au securityContext du pod:spec ou du container:spec. Je préfère le niveau du pod, car le réglage s’applique alors à tous les conteneurs du pod, sauf s’ils le remplacent explicitement. Si un autre conteneur est ajouté à ce pod, ce réglage lui sera également appliqué automatiquement.
Nous empêchons ainsi quiconque de rétablir l’exécution de l’image en tant qu’utilisateur root, car le déploiement échouera lors des tests. Dans le dépôt d’exemple, cette modification figure dans le fichier manifests/good-deployment.yaml-nonroot.
6. Spécifiez un utilisateur à l’exécution (le cas échéant)
Les plus observateurs d’entre vous auront peut-être remarqué que le second déploiement, goof-mongo, contient déjà le securityContext et spécifie également un UID.
Nous ajoutons le champ runAsUser ici parce que nous utilisons l’image de base officielle mongo de Docker Hub sans la modifier. Nous n’avons donc pas d’autre Dockerfile dans lequel définir la ligne USER. Nous faisons confiance à l’image officielle mongo pour conserver son UID. Pour notre image d’application goof, comme nous contrôlons cet UID via le Dockerfile, il est inutile de le déclarer une nouvelle fois dans le manifeste de déploiement. Cela irait à l’encontre du principe logiciel DRY.
À noter : la documentation de l’image mongo sur Docker Hub ne mentionne pas cet utilisateur ni son UID (au moment de la rédaction). Pour les trouver, j’ai exécuté docker run --rm -it mongo id afin de vérifier que le processus s’exécutait bien en tant que root. J’ai ensuite exécuté docker run --rm -it --entrypoint cat mongo /etc/passwd et repéré mongodb:x:999:999 en bas du fichier. Des tests rapides ont montré que l’exécution avec cet utilisateur fonctionne, mais dans un cas réel, vous devriez approfondir vos recherches avant d’utiliser une modification non documentée de ce type.
7. Supprimez les capacités dont votre application n’a pas besoin
En examinant cette analyse IaC, nous constatons que l’autre problème de gravité moyenne signalé est que notre conteneur utilise l’ensemble de capacités par défaut. Notre application est simple : elle n’a aucune raison d’appeler des fonctions au niveau du noyau, comme modifier la propriété des fichiers, ni de contrôler des aspects du réseau de l’hôte. Nous pouvons renforcer la sécurité en indiquant à l’environnement d’exécution du conteneur de supprimer toutes ces capacités. C’est un autre moyen de compliquer encore davantage la tâche de tout acteur malveillant qui parviendrait à pénétrer dans notre conteneur. Pour cela, il suffit d’ajouter un bloc à la spécification du conteneur afin de supprimer toutes les capacités.
Appliquez le nouveau manifeste et testez à nouveau l’application. Dans le dépôt d’exemple, le fichier manifests/good-deployment.yaml-nonroot-dropcapabilities contient ces modifications.
Vous remarquerez à nouveau que la même modification a déjà été appliquée au déploiement goof-mongo, car la base de données n’a pas non plus besoin de ces capacités.
Les paramètres securityContext de Kubernetes peuvent être complexes. Pour en savoir plus, consultez notre aide-mémoire : 10 paramètres de contexte de sécurité Kubernetes à connaître.
Notez que l’analyse Snyk IaC a également détecté plusieurs autres problèmes de faible gravité, non illustrés ci-dessus. Il convient de les corriger, mais cela dépasse le cadre de cet article.
À ce stade, je vais valider et pousser mes modifications vers GitHub. J’ai corrigé le problème de build et je ne veux pas accumuler davantage de modifications dans un même commit. Comme Snyk surveille déjà mon dépôt GitHub, l’ouverture d’une pull request de ma branche vers main déclenche une analyse rapide des vulnérabilités liées aux modifications apportées au Dockerfile. Dans ce cas, mes modifications n’ont introduit aucune vulnérabilité de sécurité ni aucun problème de licence : les tests sont donc réussis.

Nous allons maintenant fusionner ces modifications. Passons au sujet suivant pour sécuriser notre image : l’analyse des vulnérabilités.
8. Utilisez un outil d’analyse des vulnérabilités des images
Maintenant que nous avons corrigé les problèmes de sécurité liés à la configuration, intéressons-nous à la partie de l’image que nous ne contrôlons pas directement : les packages hérités de l’image de base ou ceux que nous pourrions ajouter. Pour repérer les exploits potentiels dans notre image, nous allons l’analyser à l’aide d’un outil de détection des vulnérabilités et rechercher les CVE connus dans les packages qui y sont installés.
L’analyse Snyk Container détecte 825 problèmes de niveaux de gravité variés dans cette image.
Le rapport indique qu’ils proviennent tous de l’image de base node:14.1.0 sur laquelle nous construisons notre image. C’est logique, puisque nous n’installons aucun package supplémentaire avec apt-get ou un outil équivalent dans notre Dockerfile.
Lorsqu’il y a autant de problèmes, il est souvent plus facile de les hiérarchiser dans la console Web Snyk correspondante. Mon dépôt étant déjà surveillé, examinons l’analyse déclenchée par la fusion de la pull request que nous venons d’effectuer.

Nous voyons ici un tableau hiérarchisé des vulnérabilités détectées, pondérées selon des indicateurs tels que le score CVSS, la maturité des exploits et la disponibilité actuelle des correctifs. En outre, les rapports de la CLI et du Web recommandent d’autres images de base présentant moins de vulnérabilités connues.

Dans ce cas, la mise à niveau de notre image de base vers node:fermium-buster-slim semble être la meilleure option et la plus compatible. Notez que les images portant la balise « slim » sont allégées : veillez donc à tester leur compatibilité si votre application utilise des packages qui n’y sont pas inclus.
Nous pourrions modifier notre code, mais l’interface Web propose également une correction automatisée grâce au bouton « Ouvrir une pull request de correction » situé à côté de chaque recommandation. Utilisons cette option.

Une page de confirmation détaillant la modification s’affiche, puis vous êtes directement redirigé vers la page de pull request GitHub.

Dans un cas réel, cette pull request serait soumise aux mêmes processus de revue de code et de test de l’application que toute autre modification. Pour l’instant, nous allons fusionner cette modification.
Après avoir récupéré ces modifications dans mon dépôt git local, puis reconstruit et analysé mon image, je constate que le nombre de vulnérabilités est passé à 58 ! De plus, en passant à l’image de base « slim », plus légère, nous avons réduit la taille totale de 1,03 Go à seulement 261 Mo !
9. Envisagez d’alléger encore davantage vos images
L’un des principes fondamentaux des conteneurs consiste à réduire au minimum l’environnement dans lequel s’exécute un processus. Nous avons beaucoup progressé pour alléger et sécuriser notre image, mais nous y embarquons encore bien plus que nécessaire pour le déploiement en production. Nous pourrions examiner notre image pour éliminer tous les fichiers et paquets superflus inclus dans la distribution Debian de base, mais il existe des solutions plus simples… qui impliquent certains compromis.
Images basées sur Alpine
L’une des solutions les plus simples consiste à choisir un autre système d’exploitation de base, axé sur la sécurité et la légèreté : Alpine. Les images basées sur Alpine sont réputées pour leur faible empreinte, principalement parce qu’elles contiennent très peu de paquets préinstallés. C’est un avantage du point de vue de la sécurité : les fichiers inexistants ne peuvent pas être exploités. Reconfigurons notre Dockerfile pour utiliser l’image de base node:14-alpine.
Comme vous pouvez le voir, j’ai réorganisé quelques éléments et déplacé l’application dans un autre répertoire pour respecter les conventions de l’image de base Alpine. Comme prévu, la création de cette version de notre image a considérablement réduit sa taille.
Voyons ce que révèle l’analyse Snyk Container de notre nouvelle image :
Aucun chemin vulnérable détecté ! Difficile de faire mieux !
Alors, pourquoi ne pas toujours utiliser des images de base Alpine ? Voici ce qu’indique la documentation de l’image Node :
Le principal point à noter est qu’elle utilise musl libc plutôt que glibc et les bibliothèques associées. Les logiciels rencontrent donc souvent des problèmes selon l’étendue de leurs exigences et hypothèses liées à libc. Consultez ce fil de commentaires sur Hacker News pour en savoir plus sur les problèmes susceptibles de survenir et comparer les avantages et les inconvénients des images basées sur Alpine.
Dans bien des cas, cela ne pose aucun problème, en particulier pour les plateformes comme Node, qui sont compilées de manière croisée pour Alpine. Toutefois, le passage à un ensemble entièrement différent de bibliothèques C de bas niveau peut parfois empêcher les applications de fonctionner. C’est pourquoi les scanners de vulnérabilités ne recommandent pas toujours Alpine pour réduire le nombre de vulnérabilités lorsqu’ils analysent une image basée sur glibc. Les échecs de migration vers Alpine concernent souvent des applications héritées qui dépendent de bibliothèques précompilées, comme une application Java avec des appels JNI ou une application Go qui utilise cgo. Dans tous les cas, si vous passez à Alpine depuis une autre image basée sur Linux, effectuez des tests fonctionnels et de performances complets sur votre application : vous changez, en quelque sorte, de système d’exploitation.
Images Distroless
Si Alpine ne convient pas, le projet Distroless, hébergé par Google, propose des images généralement basées sur Debian, mais réduites au strict minimum. Elles ne contiennent souvent même pas de shell permettant d’exécuter des commandes. Ces images sont maintenues par l’équipe Google Distroless. Vous trouverez plus d’informations à leur sujet dans leur dépôt GitHub.
Créer soi-même des images basées sur Scratch
L’image de base scratch n’est pas réellement une image. Il s’agit d’un mot-clé réservé dans la spécification Dockerfile qui démarre la création de votre image à partir d’un système de fichiers entièrement vierge. Pas de shell, pas de gestionnaire de paquets, rien du tout. Vous devez copier vous-même tout ce que vous souhaitez ajouter à ce type d’image. En général, scratch sert d’étape finale dans un Dockerfile utilisant des compilations multi-étapes, une fonctionnalité qui permet d’avoir plusieurs lignes FROM, la dernière déterminant l’image de base de l’image finale. Le cas d’utilisation le plus courant consiste à avoir une étape de « build » où la compilation a lieu, puis à copier l’artefact produit dans l’étape finale. Dans le cas d’une étape finale scratch, vous devez copier tout ce qui est nécessaire à l’exécution de votre programme. C’est pourquoi on utilise rarement cette approche avec les langages interprétés ou ceux qui nécessitent un environnement d’exécution, comme Node.js, Java ou Python. Elle convient bien aux langages qui se compilent en un seul fichier ou en un petit ensemble de fichiers. Les applications C, C++ et Go utilisent couramment des images scratch.
10. Étudier les outils de création d’images autres que Dockerfile
Dans cet article, nous nous sommes concentrés sur le processus de création d’images basé sur Dockerfile, de loin l’outil de création d’images le plus utilisé. Il existe toutefois d’autres moyens de créer des images sans Dockerfile. Parmi les solutions les plus populaires, citons Bazel, jib et Buildah, qui repose sur des scripts. Docker prend également en charge d’autres techniques de création à l’aide du projet BuildKit, un outil de création facultatif fourni avec le client Docker.
Conclusion et lectures complémentaires
L’exemple que nous avons étudié est relativement simple, car JavaScript est un langage interprété. En revanche, si vous travaillez avec des langages dont les phases de compilation et d’exécution sont clairement distinctes, consultez les Dockerfiles multi-étapes mentionnés plus haut. Vous pourrez ainsi continuer à effectuer des compilations dans votre Dockerfile tout en excluant le compilateur et le code source de l’image finale que vous déployez.
Pour en savoir plus sur l’exécution de Node.js dans des conteneurs, consultez 10 bonnes pratiques pour conteneuriser des applications web Node.js avec Docker. Nous proposons également une version de cet article consacrée aux applications Java : 10 bonnes pratiques pour créer un conteneur Java avec Docker.
La sécurité des conteneurs, pensée pour les développeurs
Snyk détecte et corrige automatiquement les vulnérabilités dans les images de conteneurs et les workloads Kubernetes.
Si vous utilisez les outils de conteneurisation alternatifs de Red Hat, consultez notre article de blog sur les outils en ligne de commande pour les conteneurs : utiliser Snyk avec Buildah, Podman et Skopeo.
Enfin, comme indiqué plus haut, la configuration des API de contexte de sécurité pour Kubernetes peut s’avérer complexe. L’article 10 paramètres de contexte de sécurité Kubernetes à connaître les présente et vous aidera à faire des choix sécurisés adaptés à votre application. L’une des options les plus puissantes consiste à forcer l’exécution de votre conteneur en mode lecture seule. Cela peut offrir une excellente protection contre les modifications malveillantes du système de fichiers de votre conteneur. Pour en savoir plus, consultez cette fiche pratique.
Vous souhaitez approfondir vos connaissances sur les images Docker ? Adam Gordon Bell a publié un excellent article de blog qui explique en détail comment elles sont construites. Par ailleurs, lors du 2e webinaire Docker Community All-Hands, plusieurs Docker Captains ont présenté d’excellents exposés sur les images Docker :
Bonnes pratiques pour la création d’images - Michael Irwin
Comprendre la conception des images - Brandon Mitchel
Adoption de buildx bake --push - Kevin "CrazyMax" Alvarez
Nous espérons que ce guide vous a permis de mieux comprendre le fonctionnement des images de conteneurs et la façon dont les outils peuvent vous aider à sécuriser et maintenir vos applications conteneurisées.
