10 meilleures pratiques pour conteneuriser des applications Web Node.js avec Docker
15 septembre 2022
0 minutes de lectureNote du rédacteur :
14 septembre 2022 : découvrez notre nouveau cheat sheet sur la conteneurisation des applications Web Node.js avec Docker !
Vous recherchez les meilleures pratiques pour construire des images Docker Node.js pour vos applications web ? Vous êtes au bon endroit.
L’article suivant fournit des directives de qualité pour la création d’images Docker Node.js optimisées et sécurisées. Il vous sera utile quelle que soit l’application Node.js que vous souhaitez créer. Cet article vous sera utile si :
votre objectif est de construire une application frontend en utilisant les fonctionnalités de rendu côté serveur (SSR) de Node.js pour React.
vous recherchez des conseils sur la manière de construire correctement une image Docker Node.js pour vos microservices, en exécutant Fastify, NestJS ou d’autres frameworks d’application.
Pourquoi avons-nous écrit ce guide sur la conteneurisation des applications web Docker Node.js ?
On pourrait penser qu’il s’agit d’un énième article sur la façon de construire des images Docker pour des applications Node.js. Mais de nombreux exemples que nous avons vus sur des blogs sont très simplistes et visent uniquement à vous guider sur les bases de l’exécution d’une application avec une image Docker Node.js, sans réflexion approfondie sur la sécurité et les meilleures pratiques pour construire des images Docker Node.js.
Nous allons apprendre à conteneuriser les applications web Node.js étape par étape, en commençant par un Dockerfile simple et fonctionnel, en déterminant les pièges et les risques liés à chaque directive du Dockerfile, puis en les corrigeant. Téléchargez l’aide-mémoire ici.
Une image Docker simple pour Node.js
La plupart des articles de blog que nous avons vus commencent et se terminent sur le modèle des instructions Dockerfile de base suivantes pour créer des images Docker Node.js :
FROM node
WORKDIR /usr/src/app
COPY . /usr/src/app
RUN npm install
CMD "npm" "start"
Copiez ce code dans un fichier Dockerfile
, puis créez-le et exécutez-le.
$ docker build . -t nodejs-tutorial
$ docker run -p 3000:3000 nodejs-tutorial
C’est simple et ça fonctionne.
Le seul problème ? Ce code est plein d’erreurs et de mauvaises pratiques pour construire des images Docker Node.js. Évitez ca autant que possible.
Commençons à améliorer ce Dockerfile pour pouvoir créer des applications web Node.js optimisées avec Docker.
Vous pouvez suivre ce tutoriel en clonant ce référentiel.
Suivez ces 10 étapes pour créer des applications web Node.js optimisées avec Docker :
Utilisez des balises d’image de base Docker explicites et déterministes
Installez uniquement les dépendances stables dans l’image Docker de Node.js
Optimisez l’outillage de production de Node.js
N’exécutez pas les conteneurs en tant que root
Quittez en toute sécurité les applications web Docker Node.js
Quittez normalement vos applications web Node.js
Détectez et corrigez les vulnérabilités de sécurité dans votre image Docker Node.js
Utilisez des builds en plusieurs étapes
Empêchez les fichiers inutiles d’apparaître dans vos images Docker Node.js
Montez des secrets dans l’image de build Docker
1. Utilisez des balises d’image de base Docker explicites et déterministes
Il peut sembler évident de construire votre image en se basant sur l’image Docker du nœud
. Mais savez-vous réellement ce qui est importé lorsque vous construisez l’image ? Les images Docker sont toujours référencées par des balises. Lorsque vous n’en spécifiez pas, c’est la balise par défaut, :latest
qui est utilisée.
Donc, en fait, en spécifiant ce qui suit dans votre Dockerfile, vous construisez toujours la dernière version de l’image Docker qui a été construite par le groupe de travail Docker Node.js :
FROM node
Les inconvénients de la construction basée sur l’imagenode
par défaut sont les suivants :
Les builds d’images Docker sont incohérentes. De la même manière que nous utilisons des
lockfiles
pour obtenir un comportement déterministe denpm install
à chaque fois que nous installons des paquets npm, nous aimerions obtenir des builds déterministes d’images Docker. Si nous construisons l’image à partir de node (ce qui signifie concrètement, le tagnode:latest
), à chaque build, une nouvelle image Docker sera extraite pournode
. Il n’est pas souhaitable d’introduire ce type de comportement non déterministe.L’image Docker pour node est basée sur un système d’exploitation à part entière comportant une multitude de bibliothèques et d’outils dont vous pouvez avoir besoin ou non pour l’exécution de votre application web Node.js. Cela présente deux inconvénients. Premièrement, une image plus grande implique une taille de téléchargement plus importante, ce qui, en plus d’augmenter les besoins de stockage, allonge le temps de téléchargement et de reconstruction de l’image. Deuxièmement, cela signifie que vous introduisez potentiellement dans l’image les vulnérabilités de sécurité susceptibles d’être présentes dans ces bibliothèques et outils.
En fait, l’image Docker de node
est volumineuse et comporte des centaines de vulnérabilités de sécurité de différents types et degrés de gravité. L’utiliser revient à une base de départ comportant 642 vulnérabilités de sécurité et des centaines de mégaoctets de données d’image téléchargées à chaque extraction et à chaque build.
Voici quelques recommandations pour construire des images Docker de meilleure qualité :
Utilisez des images Docker de petite taille : son empreinte logicielle sera moins importante, ce qui réduira les vecteurs de vulnérabilité potentiels, et accélérera le processus de construction de l’image.
Utilisez le digest de l’image Docker, c’est-à-dire le hachage SHA256 statique de l’image. Vous êtes ainsi certain(e) d’obtenir des builds d’images Docker déterministes à partir de l’image de base.
Nous avons consacré un article complet sur la façon de choisir la meilleure image Docker Node.js L’article explique en détail pourquoi une distribution Debian slim à jour avec une version d’exécution Node.js LTS constitue le choix idéal.
L’image Docker Node.js recommandée à utiliser est la suivante :
FROM node:20.9.0-bullseye-slim
Cette image Docker Node.js utilise une version spécifique de l’environnement d’exécution Node.js (`16.17.0`) qui correspond à la dernière version actuelle de Long Term Support. La variante d’image `bullseye`, qui est la version stable actuelle de Debian 11, est utilisée avec une date de fin de vie suffisamment éloignée. Enfin, la variante d’image `slim` permet de spécifier une empreinte logicielle plus petite du système d’exploitation, qui se traduit par une taille d’image inférieure à 200 Mo, environnement d’exécution et outils Node.js compris.
Cela dit, l’une des pratiques courantes non informées que vous verrez est celle de tutoriels ou de guides citant l’instruction Docker suivante pour une image de base :
FROM node:alpine
Ces articles citent l’utilisation de l’image Docker Node.js Alpine. Est-ce la solution idéale ? On attribue généralement à l’image Docker Node.js Alpine une empreinte logicielle plus réduite. Toutefois, le fait qu’elle soit sensiblement différente par d’autres caractéristiques en fait une image de base de production non optimale pour les runtimes d’applications Node.js.
Qu’est-ce que Node Alpine ?
Node.js Alpine est une image Docker non officielle maintenue par l’équipe Docker Node.js. L’image Node.js contient le système d’exploitation Alpine qui s’appuie sur l’outil logiciel minimal busybox et sur l’implémentation de la bibliothèque C musl. Ces deux caractéristiques de l’image Node.js Alpine contribuent à ce que l’image Docker soit officieusement soutenue par l’équipe Node.js. En outre, de nombreux outils d’analyse des vulnérabilités de sécurité ne sont pas en mesure de détecter facilement les runtimes et les artefacts logiciels sur les images Node.js Alpine, ce qui va à l’encontre des efforts visant à sécuriser vos images de conteneurs.
Indépendamment de l’utilisation de la balise d’image Node.js Alpine, l’utilisation d’une directive d’image de base sous la forme d’un alias pourrait toujours permettre l’extraction de nouveaux builds de cette balise, les balises d’image Docker étant mutables. Nous pouvons trouver le hachage SHA256
dans le Docker Hub pour cette balise Node.js ou en exécutant la commande suivante après avoir extrait cette image localement, et localiser le champ Digest
dans la sortie :
$ docker pull node:20.9.0-bullseye-slim
20.9.0-bullseye-slim: Pulling from library/node
ca426296fe92: Pull complete
0d5f60f923bb: Pull complete
cc6fa81c4559: Pull complete
ec5e8e3b63b3: Pull complete
ca7cb04b0758: Pull complete
Digest: sha256:330fa0342b6ad2cbdab30ac44195660af5a1f298cc499d8cbdf7496b02ea17d8
Status: Downloaded newer image for node:20.9.0-bullseye-slim
docker.io/library/node:20.9.0-bullseye-slim
Un autre moyen pour trouver le hachage SHA256
consiste à exécuter la commande suivante :
$ docker images --digests
REPOSITORY TAG DIGEST IMAGE ID CREATED SIZE
node 20.9.0-bullseye-slim sha256:330fa0342b6ad2cbdab30ac44195660af5a1f298cc499d8cbdf7496b02ea17d8 9ea15fe618bd 7 days ago 200MB
Nous pouvons maintenant mettre à jour le Dockerfile de cette image Docker Node.js comme suit :
FROM node@sha256:330fa0342b6ad2cbdab30ac44195660af5a1f298cc499d8cbdf7496b02ea17d8
WORKDIR /usr/src/app
COPY . /usr/src/app
RUN npm install
CMD "npm" "start"
Toutefois, le Dockerfile ci-dessus se contente de spécifier le nom de l’image Docker Node.js, sans fournir de balise d’image. La balise d’image utilisée n’est ainsi pas évidente à déterminer. Elle n’est pas lisible et difficile à maintenir. Bref, l’expérience n’est pas positive pour les développeurs.
Corrigeons cela en mettant à jour le Dockerfile, en fournissant la balise de base complète de la version de Node.js correspondant à ce hachage SHA256
:
FROM node:20.9.0-bullseye-slim@sha256:330fa0342b6ad2cbdab30ac44195660af5a1f298cc499d8cbdf7496b02ea17d8
WORKDIR /usr/src/app
COPY . /usr/src/app
RUN npm install
CMD "npm" "start"
L’utilisation du digest d’image Docker garantit une image déterministe, mais peut être déroutante ou contre-productive pour certains outils d’analyse d’image qui ne savent pas comment l’interpréter. Pour cette raison, il est préférable d’utiliser une version explicite du runtime de Node.js, telle que `16.17.0`. Même si, en théorie, elle est mutable et peut être remplacée, en pratique, si elle doit recevoir des mises à jour de sécurité ou autres, celles-ci seront poussées vers une nouvelle version telle que `16.17.1`, ce qui la rend assez sûre pour des constructions déterministes.
Notre proposition finale de Dockerfile à ce stade est donc la suivante :
FROM node:16.17.0-bullseye-slim
WORKDIR /usr/src/app
COPY . /usr/src/app
RUN npm install
CMD "npm" "start"
Lisez d’autres conseils et meilleures pratiques pour créer des images de conteneurs sécurisées.
2. Installez uniquement les dépendances stables dans l’image Docker de Node.js
La directive Dockerfile suivante installe toutes les dépendances dans le conteneur, y compris les devDependencies
, qui ne sont pas nécessaires au fonctionnement d’une application fonctionnelle. Elle ajoute un risque de sécurité inutile lié aux paquets utilisés comme dépendances de développement, tout en gonflant inutilement la taille de l’image.
RUN npm install
Si vous avez suivi mon guide précédent sur les 10 meilleures pratiques de sécurité NPM, vous savez que vous voulez renforcer les constructions déterministes avec npm ci
. Cela permet d’éviter les surprises dans un flux d’intégration continue (CI), car il s’arrête en cas de déviation du lockfile (fichier de verrouillage).
Dans le cas de la création d’une image Docker pour la production, nous voulons nous assurer d’installer uniquement les dépendances de production de manière déterministe, ce qui nous amène à la recommandation suivante en termes de meilleure pratique d’installation des dépendances npm dans une image de conteneur :
RUN npm ci --only=production
Le contenu du Dockerfile mis à jour à cette étape est le suivant :
FROM node:20.9.0-bullseye-slim
WORKDIR /usr/src/app
COPY . /usr/src/app
RUN npm ci --only=production
CMD "npm" "start"
Lisez notre article sur les dépendances logicielles pour en savoir plus.
3. Optimisez l’outillage de production de Node.js
Lorsque vous construisez votre image Docker Node.js pour la production, il est important de vous assurer que l’ensemble des frameworks et bibliothèques utilisent des paramètres de performance et de sécurité optimum.
Cela nous amène à ajouter la directive Dockerfile suivante :
ENV NODE_ENV production
À première vue, cela semble redondant, puisque nous avons déjà spécifié uniquement les dépendances de production à la phase npm install
. Alors pourquoi est-ce nécessaire ?
Les développeurs associent généralement le paramètre de variable d’environnement NODE_ENV=production
à l’installation des dépendances liées à la production, pourtant ce paramètre a aussi d’autres effets dont nous devons être conscients.
Certains frameworks et bibliothèques ne peuvent activer la configuration optimisée adaptée à la production que si la variable d’environnement NODE_ENV
est définie sur production
. Que l’on soit pour ou contre, il est important de le savoir.
À titre d’exemple, la documentation d’Express souligne l’importance de définir cette variable d’environnement pour activer les optimisations liées aux performances et à la sécurité :
L’impact sur les performances de la variable NODE_ENV
peut être très important.
L’équipe de Dynatrace a rédigé un article de blog qui explique en détail les effets dramatiques de l’omission de NODE_ENV dans vos applications Express.
De nombreuses autres bibliothèques sur lesquelles vous vous appuyez pouvant également s’attendre à ce que cette variable soit définie, il est donc recommandé de la définir dans notre Dockerfile.
Le contenu du Dockerfile mis à jour doit maintenant ressembler à ce qui suit, avec le paramètre de la variable d’environnement NODE_ENV
intégré :
FROM node:20.9.0-bullseye-slim
ENV NODE_ENV production
WORKDIR /usr/src/app
COPY . /usr/src/app
RUN npm ci --only=production
CMD "npm" "start"
4. N’exécutez pas les conteneurs en tant que root
Le principe du moindre privilège est un principe de sécurité qui remonte aux prémices d’Unix. Nous devrions toujours le suivre lorsque nous exécutons nos applications web Node.js conteneurisées.
L’évaluation de la menace est assez simple : si un attaquant est en mesure de compromettre l’application web de manière à permettre l’injection de commande ou la traversée de chemins de répertoire, ceux-ci seront invoqués avec l’utilisateur qui détient le processus d’application. Si ce processus s’exécute en tant que root, il peut alors faire pratiquement tout ce qu’il veut dans le conteneur, y compris tenter de s’échapper du conteneur ou élever ses privilèges. Pourquoi voudrions-nous prendre ce risque ? Vous avez raison, nous ne le souhaitons pas.
Répétez après moi : « Les amis ne laissent pas leurs amis exécuter des conteneurs en tant que root ! »
L’image Docker officielle Node
, ainsi que ses variantes comme Alpine
, incluent un utilisateur moins privilégié du même nom : Node
. Cependant, se contenter d’exécuter le processus en tant que node
n’est pas suffisant. Par exemple, le code qui suit n’est pas idéal pour le fonctionnement de l’application :
USER node
CMD "npm" "start"
La raison en est que la directive USER
Dockerfile garantit uniquement que le processus appartient à l’utilisateur node
. Qu’en est-il de tous les fichiers que nous avons copiés précédemment avec l’instruction COPY
? Ils appartiennent à l’utilisateur root. C’est ainsi que Docker fonctionne par défaut.
Voici comment supprimer des privilèges de manière complète et correcte, et afficher des pratiques Dockerfile à jour :
FROM node:20.9.0-bullseye-slim
ENV NODE_ENV production
WORKDIR /usr/src/app
COPY --chown=node:node . /usr/src/app
RUN npm ci --only=production
USER node
CMD "npm" "start"
5. Quittez en toute sécurité les applications web Docker Node.js
L’une des erreurs les plus courantes que je vois dans les blogs et les articles sur la conteneurisation des applications Node.js lorsqu’elles fonctionnent dans des conteneurs Docker est la façon dont elles invoquent le processus. Tous les éléments suivants et leurs variantes sont de mauvais modèles que nous vous recommandons d’éviter :
CMD “npm” “start”
CMD [“yarn”, “start”]
CMD “node” “server.js”
CMD “start-app.sh”
C’est parti ! Je vais vous expliquer pourquoi chacun de ces processus d’invocation défaillant doit être évité.
Les points suivants sont essentiels à la compréhension du contexte dans lequel s’exécutent et se terminent correctement les applications Docker Node.js :
Un moteur d’orchestration, tel que Docker Swarm, Kubernetes, ou même simplement le moteur Docker, nécessite un moyen d’envoyer des signaux au processus dans le conteneur. La plupart du temps, il s’agit de signaux permettant de mettre fin à une application, tels que
SIGTERM
etSIGKILL
.Le processus peut s’exécuter indirectement, et dans ce cas, il n’est pas toujours garanti qu’il recevra ces signaux.
Le noyau Linux traite les processus qui s’exécutent sous l’ID de processus 1 (PID) différemment de tout autre ID de processus.
Forts de ces connaissances, commençons à étudier les moyens d’invoquer le processus d’un conteneur, en partant de l’exemple du Dockerfile que nous construisons :
CMD "npm" "start"
Le problème ici est double. Premièrement, nous exécutons indirectement l’application node en invoquant directement le client npm. Qui peut dire que la CLI de npm transmet tous les événements au runtime de node ? En fait, il ne le fait pas, et nous pouvons facilement le tester.
Assurez-vous que dans votre application Node.js, vous définissez un gestionnaire d’événements pour le signal SIGHUP
qui crée un enregistrement dans la console chaque fois que vous envoyez un événement. Un exemple de code simple devrait ressembler à ceci :
function handle(signal) {
console.log(`*^!@4=> Received event: ${signal}`)
}
process.on('SIGHUP', handle)
Exécutez ensuite le conteneur et, une fois qu’il est en place, envoyez-lui le signal SIGHUP
à l’aide de la CLI de docker
et de l’indicateur de ligne de commande spécial --signal
:
$ docker kill --signal=SIGHUP elastic_archimedes
Il ne s’est rien passé, n’est-ce pas ? Ceci est dû au fait que le client npm ne transmet aucun signal au processus node qu’il a engendré.
L’autre réserve concerne les différentes manières dont vous pouvez spécifier la directive CMD
dans le Dockerfile. Il y a deux façons différentes de le faire :
la notation shellform, dans laquelle le conteneur génère un interpréteur de shell qui enveloppe le processus. Dans ce cas, le shell peut ne pas transmettre correctement les signaux à votre processus.
la notation execform, qui génère directement un processus sans l’envelopper dans un shell. Elle est spécifiée au moyen de la notation de tableau JSON, comme suit :
CMD [“npm”, “start”]
. Tous les signaux envoyés au conteneur sont directement envoyés au processus.
Sur la base de ces connaissances, nous voulons améliorer notre directive d’exécution de processus Dockerfile comme suit :
CMD ["node", "server.js"]
Nous invoquons maintenant directement le processus node, en nous assurant qu’il reçoit tous les signaux qui lui sont envoyés, sans être enveloppé dans un interpréteur de shell.
Cela introduit toutefois un nouveau piège.
Lorsque les processus s’exécutent en tant que PID 1, ils assument effectivement certaines des responsabilités d’un système init, qui est généralement responsable de l’initialisation d’un système d’exploitation et des processus. Le noyau traite le PID 1 différemment des autres identifiants de processus. Ce traitement spécial du noyau signifie que la gestion du signal SIGTERM
reçu par un processus en cours d’exécution n’invoquera pas par défaut un arrêt brutal du processus si celui-ci n’a pas déjà défini un gestionnaire pour ce signal.
Citons la recommandation du groupe de travail Docker Node.js à ce sujet : « Node.js n’a pas été conçu pour fonctionner en tant que PID 1, ce qui entraîne un comportement inattendu lors de l’exécution dans Docker. Par exemple, un processus Node.js s’exécutant en tant que PID 1 ne répondra pas à SIGINT (CTRL-C) et aux signaux similaires ».
La façon de procéder consiste donc à utiliser un outil qui agira comme un processus init en ce sens qu’il est invoqué avec le PID 1, puis génère notre application Node.js comme un autre processus tout en s’assurant que tous les signaux sont transmis par procuration à ce processus Node.js. Dans la mesure du possible, nous souhaitons que l’empreinte de l’outil soit la plus petite possible afin de ne pas risquer d’ajouter des failles de sécurité à notre image de conteneur.
Chez Snyk, nous utilisons notamment l’outil dumb-init qui est lié statiquement et présente une empreinte réduite. Voici comment nous allons le configurer :
RUN apt-get update && apt-get install -y --no-install-recommends dumb-init
CMD ["dumb-init", "node", "server.js"]
Ceci nous amène au Dockerfile
suivant, à jour. Remarque : nous avons placé l’installation du paquet dumb-init
juste après la déclaration de l’image, ceci afin de profiter de la mise en cache des couches par Docker :
FROM node:20.9.0-bullseye-slim
RUN RUN apt-get update && apt-get install -y --no-install-recommends dumb-init
ENV NODE_ENV production
WORKDIR /usr/src/app
COPY --chown=node:node . .
RUN npm ci --only=production
USER node
CMD ["dumb-init", "node", "server.js"]
Lorsque nous utilisons l’instruction RUN
de Docker pour ajouter des logiciels, comme nous l’avons fait avec RUN apt-get update && apt-get install
, nous laissons derrière nous certaines informations sur l’image Docker. Pour faire le ménage après cette commande, nous pouvons l’étendre comme suit et conserver ainsi une image Docker plus mince :
FROM node:20.9.0-bullseye-slim
RUN apt-get update && apt-get install -y --no-install-recommends dumb-init
ENV NODE_ENV production
WORKDIR /usr/src/app
COPY --chown=node:node . .
RUN npm ci --only=production
USER node
CMD ["dumb-init", "node", "server.js"]
Astuce : il est même préférable d’installer l’outil dumb-init
dans une image d’étape de build antérieure, puis de copier le fichier /usr/bin/dumb-init
résultant dans l’image de conteneur finale pour garder cette image propre. Plus loin dans ce guide, nous en apprendrons davantage sur les builds Docker en plusieurs étapes.
Bon à savoir : les commandes docker kill
et docker stop
envoient seulement des signaux au processus du conteneur avec le PID 1. Si vous exécutez un script shell qui exécute votre application Node.js, sachez qu’une instance shell (comme /bin/sh
, par exemple) ne transmet pas de signaux aux processus enfants, ce qui signifie que votre application ne recevra jamais de SIGTERM
.
6. Quittez normalement vos applications web Node.js
Puisque nous parlons des signaux de processus qui mettent fin aux applications, assurons-nous de les arrêter correctement et en douceur, sans perturber les utilisateurs.
Lorsqu’une application Node.js reçoit un signal d’interruption, également connu sous le nom de SIGINT
, ou CTRL+C
, elle provoque un arrêt brutal du processus, à moins que des gestionnaires d’événements n’aient été configurés pour le gérer différemment. Cela signifie que les clients connectés à une application web seront immédiatement déconnectés. Imaginez maintenant des centaines de conteneurs web Node.js orchestrés par Kubernetes, qui augmentent et diminuent selon les besoins pour faire évoluer le processus ou gérer les erreurs. Ce n’est pas vraiment la meilleure expérience utilisateur.
Vous pouvez facilement simuler ce problème. Voici un exemple d’application web Fastify présentant un retard de réponse inhérent de 60 secondes pour une node :
fastify.get('/delayed', async (request, reply) => {
const SECONDS_DELAY = 60000
await new Promise(resolve => {
setTimeout(() => resolve(), SECONDS_DELAY)
})
return { hello: 'delayed world' }
})
const start = async () => {
try {
await fastify.listen(PORT, HOST)
console.log(`*^!@4=> Process id: ${process.pid}`)
} catch (err) {
fastify.log.error(err)
process.exit(1)
}
}
start()
Exécutez cette application et, durant son exécution, envoyez une simple requête HTTP à cette node :
$ time curl https://localhost:3000/delayed
Si vous appuyez sur CTRL+C
dans la fenêtre de la console Node.js en cours d’exécution, la requête curl s’arrête brusquement. Cela simule l’expérience de vos utilisateurs en cas de destruction des conteneurs.
Quelques petites choses à faire pour offrir une meilleure expérience :
Définissez un gestionnaire d’événements pour la gestion des signaux de terminaison tels que
SIGINT
etSIGTERM
.Le gestionnaire attend la survenue d’opérations de nettoyage telles que les connexions aux bases de données, les requêtes HTTP en cours et autres.
Le gestionnaire met ensuite fin au processus Node.js.
Dans le cas spécifique de Fastify, nous pouvons faire en sorte que notre gestionnaire appelle la commande fastify.close() qui renvoie une promesse que nous attendrons ; Fastify prendra également soin de répondre à chaque nouvelle connexion avec le code d’état HTTP 503 pour signaler que l’application est indisponible.
Ajoutons notre gestionnaire d’événements :
async function closeGracefully(signal) {
console.log(`*^!@4=> Received signal to terminate: ${signal}`)
await fastify.close()
// await db.close() if we have a db connection in this app
// await other things we should cleanup nicely
process.kill(process.pid, signal);
}
process.once('SIGINT', closeGracefully)
process.once('SIGTERM', closeGracefully)
Certes, il s’agit davantage d’un problème relevant des applications web génériques que d’un problème lié à Dockerfile, mais son importance est d’autant plus grande dans les environnements orchestrés.
7. Détectez et corrigez les vulnérabilités de sécurité dans votre image Docker Node.js
Nous avons déjà discuté de l’importance d’utiliser de petites images de base Docker pour nos applications Node.js. Mettons ce test en pratique.
Je vais utiliser la CLI de Snyk pour tester notre image Docker. Vous pouvez créer un compte Snyk gratuit ici.
$ npm install -g snyk
$ snyk auth
$ snyk container test node:20.9.0-bullseye-slim --file=Dockerfile
La première commande installe la CLI de Snyk, suivie d’un flux de connexion rapide à partir de la ligne de commande de récupération d’une clé API ; ensuite, nous pouvons tester le conteneur afin de détecter d’éventuels problèmes de sécurité. Voici le résultat :
Organization: lirantal
Package manager: deb
Target file: Dockerfile
Project name: docker-image|node
Docker image: node:20.9.0-bullseye-slim
Platform: linux/arm64
Base image: node:lts-bullseye-slim
Licenses: enabled
Tested 97 dependencies for known issues, found 44 issues.
According to our scan, you are currently using the most secure version of the selected base image
Snyk a détecté 97 dépendances du système d’exploitation, y compris notre exécutable Node.js, et n’a trouvé aucune version vulnérable de l’environnement d’exécution. Toutefois, 44 vulnérabilités de sécurité existent pour certains des logiciels présents dans l’image du conteneur. 43 de ces dépendances sont des problèmes de faible gravité ; 1 est une vulnérabilité critique liée à la bibliothèque zlib :
✗ Low severity vulnerability found in apt/libapt-pkg6.0
Description: Improper Verification of Cryptographic Signature
Info: https://snyk.io/vuln/SNYK-DEBIAN11-APT-522585
Introduced through: apt/libapt-pkg6.0@2.2.4, apt@2.2.4
From: apt/libapt-pkg6.0@2.2.4
From: apt@2.2.4 > apt/libapt-pkg6.0@2.2.4
From: apt@2.2.4
Image layer: Introduced by your base image (node:lts-bullseye-slim)
✗ Critical severity vulnerability found in zlib/zlib1g
Description: Out-of-bounds Write
Info: https://snyk.io/vuln/SNYK-DEBIAN11-ZLIB-2976151
Introduced through: meta-common-packages@meta
From: meta-common-packages@meta > zlib/zlib1g@1:1.2.11.dfsg-2+deb11u1
Image layer: Introduced by your base image (node:lts-bullseye-slim)
Fixed in: 1:1.2.11.dfsg-2+deb11u2
Comment corriger les vulnérabilités d’une image Docker ?
Une façon efficace et rapide de se tenir à jour en termes de sécurité logicielle dans votre image Docker consiste à reconstruire l’image Docker. Vous dépendez de l’image de base Docker en amont que vous utilisez pour récupérer ces mises à jour. Une autre méthode consiste à installer explicitement les mises à jour du système d’exploitation pour les paquets, y compris les correctifs de sécurité.
Avec l’image Docker Node.js officielle, il se peut que l’équipe soit plus lente à gérer les mises à jour d’image, ainsi, la reconstruction de l’image Docker de Node.js 16.17.0-bullseye-slim
ou lts-bullseye-slim
ne sera pas efficace. L’autre option consiste à gérer votre propre image de base avec les logiciels à jour de Debian. Dans notre Dockerfile
, nous pouvons le faire comme suit :
RUN apt-get update && apt-get upgrade -y
Exécutons l’outil d’analyse de sécurité Snyk après avoir construit l’image Docker Node.js avec l’instruction RUN
nouvellement ajoutée suivante :
✗ Low severity vulnerability found in apt/libapt-pkg6.0
Description: Improper Verification of Cryptographic Signature
Info: https://snyk.io/vuln/SNYK-DEBIAN11-APT-522585
Introduced through: apt/libapt-pkg6.0@2.2.4, apt@2.2.4
From: apt/libapt-pkg6.0@2.2.4
From: apt@2.2.4 > apt/libapt-pkg6.0@2.2.4
From: apt@2.2.4
Image layer: Introduced by your base image (node:20.9.0-bullseye-slim)
…
Tested 98 dependencies for known issues, found 43 issues.
According to our scan, you are currently using the most secure version of the selected base image
Résultat : une dépendance supplémentaire a été ajoutée au système d’exploitation (98 contre 97 auparavant), mais les 43 vulnérabilités de sécurité impactant cette image Docker Node.js sont à présent de faible gravité et nous avons corrigé la faille de sécurité critique zlib. C’est une grande victoire pour nous !
Que se serait-il passé si nous avions utilisé la directive d’image de base FROM node
? Encore mieux, imaginons que vous aviez utilisé une image de base Docker Node.js plus spécifique, comme celle-ci :
FROM node:14.2.0-slim
…
✗ High severity vulnerability found in node
Description: Memory Corruption
Info: https://snyk.io/vuln/SNYK-UPSTREAM-NODE-570870
Introduced through: node@14.2.0
From: node@14.2.0
Introduced by your base image (node:14.2.0-slim)
Fixed in: 14.4.0
✗ High severity vulnerability found in node
Description: Denial of Service (DoS)
Info: https://snyk.io/vuln/SNYK-UPSTREAM-NODE-674659
Introduced through: node@14.2.0
From: node@14.2.0
Introduced by your base image (node:14.2.0-slim)
Fixed in: 14.11.0
Organization: snyk-demo-567
Package manager: deb
Target file: Dockerfile
Project name: docker-image|node
Docker image: node:14.2.0-slim
Platform: linux/amd64
Base image: node:14.2.0-slim
Tested 78 dependencies for known issues, found 82 issues.
Base Image Vulnerabilities Severity
node:14.2.0-slim 82 23 high, 11 medium, 48 low
Recommendations for base image upgrade:
Minor upgrades
Base Image Vulnerabilities Severity
node:14.15.1-slim 71 17 high, 7 medium, 47 low
Major upgrades
Base Image Vulnerabilities Severity
node:15.4.0-slim 71 17 high, 7 medium, 47 low
Alternative image types
Base Image Vulnerabilities Severity
node:14.15.1-buster-slim 55 12 high, 4 medium, 39 low
node:14.15.3-stretch-slim 71 17 high, 7 medium, 47 low
Il semble qu’une version spécifique du runtime Node.js telle que FROM node:14.2.0-slim
soit suffisante car vous avez indiqué une version spécifique (`14.2.0`) et utilisé une image de conteneur de petite taille (grâce à la balise d’image `slim`). Snyk est néanmoins capable de détecter des vulnérabilités de sécurité provenant de 2 sources principales :
L’environnement d’exécution Node.js : avez-vous remarqué les deux principales vulnérabilités de sécurité dans le rapport ci-dessus ? Ces problèmes de sécurité de l’environnement d’exécution Node.js sont connus du grand public. La solution immédiate consisterait à faire une mise à niveau vers une version plus récente de Node.js. Snyk vous en informe et vous dit quelle version utiliser (14.11.0).
Les outils et bibliothèques installés dans cette image de base Debian, tels que glibc, bzip2, gcc, perl, bash, tar, libcrypt et autres. Bien que la présence de ces versions vulnérables dans le conteneur ne constitue pas forcément une menace immédiate, pourquoi les conserver si nous ne les utilisons pas ?
Le meilleur de ce rapport sur la CLI de Snyk ? Snyk recommande également des images de base alternatives à utiliser, pour vous éviter d’avoir à le faire vous-même. En effet, cela peut prendre beaucoup de temps.
Ma recommandation à ce stade est la suivante :
Si vous gérez vos images Docker dans un registre, comme Docker Hub ou Artifactory, vous pouvez facilement les importer dans Snyk afin que la plateforme détecte ces vulnérabilités elle-même. Vous obtiendrez également des conseils et recommandations dans l’interface utilisateur de Snyk, et la surveillance permanente de vos images Docker afin de détecter les vulnérabilités de sécurité nouvellement découvertes.
Utilisez la CLI de Snyk dans l’automatisation de votre CI. La CLI est un outil très flexible et c’est exactement la raison pour laquelle nous l’avons créée : vous pouvez l’appliquer à tous vos flux de travail personnalisés. Si vous le souhaitez, nous avons également Snyk for GitHub Actions.
Pour découvrir d’autres moyens de gérer les vulnérabilités des images de conteneurs, consultez notre guide sur la sécurité des conteneurs.
8. Utilisez des builds en plusieurs étapes
Les builds en plusieurs étapes constituent un excellent moyen de passer d’un Dockerfile simple, mais potentiellement erroné, à des étapes séparées de création d’une image Docker, afin d’éviter la fuite d’informations sensibles. Nous pouvons en outre également utiliser une image de base Docker plus grande pour installer nos dépendances, compiler si nécessaire les paquets npm natifs, puis copier l’ensemble de ces artefacts dans une petite image de base de production, comme dans notre exemple d’image basée sur Alpine.
Évitez les fuites d’informations sensibles
Le présent cas d’utilisation destiné à éviter les fuites d’informations sensibles est plus courant qu’on ne le pense.
Si vous créez des images Docker pour votre travail, il y a de fortes chances que vous teniez également à jour des paquets npm privés. Dans ce cas, vous avez certainement dû trouver un moyen de rendre le NPM_TOKEN
secret accessible à l’installation de npm.
Voici un exemple pour illustrer mon propos :
FROM node:20.9.0-bullseye-slim
RUN apt-get update && apt-get install -y --no-install-recommends dumb-init
ENV NODE_ENV production
ENV NPM_TOKEN 1234
WORKDIR /usr/src/app
COPY --chown=node:node . .
#RUN npm ci --only=production
RUN echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > .npmrc && \
npm ci --only=production
USER node
CMD ["dumb-init", "node", "server.js"]
Toutefois, en procédant ainsi, le fichier .npmrc
contenant le jeton npm secret reste dans l’image Docker. Vous pouvez essayer d’améliorer les choses en supprimant par la suite le fichier, comme ceci :
RUN echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > .npmrc && \
npm ci --only=production
RUN rm -rf .npmrc
Mais le fichier .npmrc
est maintenant disponible dans une couche différente de l’image Docker. Si cette image Docker est publique, ou si quelqu’un parvient à y accéder d’une manière ou d’une autre, alors la confidentialité de votre jeton est compromise. Une meilleure chose à faire serait :
RUN echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > .npmrc && \
npm ci --only=production; \
rm -rf .npmrc
Le problème est que le Dockerfile lui-même, qui contient à présent le jeton npm secret, doit être traité comme un actif secret.
Heureusement, Docker prend en charge un moyen de transférer des arguments dans le processus de build :
ARG NPM_TOKEN
RUN echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > .npmrc && \
npm ci --only=production; \
rm -rf .npmrc
Ensuite, nous le construisons comme suit :
$ docker build . -t nodejs-tutorial --build-arg NPM_TOKEN=1234
Vous espériez que nous en ayons terminé à ce stade, mais ce n’est malheureusement pas le cas.
Il en va ainsi de la sécurité : parfois, les choses les plus évidentes ne sont qu’un piège de plus.
Vous vous demandez quel est le problème à présent ? Les arguments de build transmis à Docker de cette manière sont conservés dans le journal d’historique. Voyons cela. Exécutez la commande suivante :
$ docker history nodejs-tutorial
qui imprime ce qui suit :
IMAGE CREATED CREATED BY SIZE COMMENT
b4c2c78acaba About a minute ago CMD ["dumb-init" "node" "server.js"] 0B buildkit.dockerfile.v0
<missing> About a minute ago USER node 0B buildkit.dockerfile.v0
<missing> About a minute ago RUN |1 NPM_TOKEN=1234 /bin/sh -c echo "//reg… 5.71MB buildkit.dockerfile.v0
<missing> About a minute ago ARG NPM_TOKEN 0B buildkit.dockerfile.v0
<missing> About a minute ago COPY . . # buildkit 15.3kB buildkit.dockerfile.v0
<missing> About a minute ago WORKDIR /usr/src/app 0B buildkit.dockerfile.v0
<missing> About a minute ago ENV NODE_ENV=production 0B buildkit.dockerfile.v0
Avez-vous remarqué le jeton npm secret ? Vous avez compris.
Il existe un excellent moyen de gérer les secrets dans l’image du conteneur, mais voici venu le moment d’introduire les builds en plusieurs étapes pour atténuer ce problème. Nous verrons aussi comment construire des images minimales.
Introduction des builds en plusieurs étapes pour les images Docker Node.js
Nous allons nous inspirer des idées du principe de séparation des problèmes appliqué en développement logiciel pour construire nos images Docker Node.js. Nous nous servirons d’une image pour construire tout ce dont nous avons besoin pour que l’application Node.js fonctionne, ce qui, dans le monde de Node.js, signifie installer des paquets npm et compiler des modules npm natifs si nécessaire. Ce sera là la première étape.
La deuxième image Docker, qui constitue la deuxième étape de la construction Docker, sera l’image Docker de production. Durant cette deuxième et dernière étape, nous optimisons et publions l’image dans un registre, si nous en avons un. La première image, que nous appellerons l’image build
, est mise au rebut et laissée en suspens dans l’hôte Docker qui l’a construite, jusqu’à ce qu’elle soit nettoyée.
Voici la mise à jour de notre Dockerfile qui représente notre progression jusqu’à présent, mais séparée en deux étapes :
__# --------------> The build image__
FROM node:latest AS build
RUN apt-get update && apt-get install -y --no-install-recommends dumb-init
ARG NPM_TOKEN
WORKDIR /usr/src/app
COPY package*.json /usr/src/app/
RUN echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > .npmrc && \
npm ci --only=production && \
rm -f .npmrc
__# --------------> The production image__
FROM node:20.9.0-bullseye-slim
ENV NODE_ENV production
COPY --from=build /usr/bin/dumb-init /usr/bin/dumb-init
USER node
WORKDIR /usr/src/app
COPY --chown=node:node --from=build /usr/src/app/node_modules /usr/src/app/node_modules
COPY --chown=node:node . /usr/src/app
CMD ["dumb-init", "node", "server.js"]
Comme vous pouvez le voir, j’ai choisi une image plus grande pour l’étape de build
car il se peut que j’aie besoin d’outils comme gcc
(la collection de compilateurs GNU) pour compiler les paquets npm natifs, ou pour d’autres besoins.
Dans la deuxième étape, il y a une notation spéciale pour la directive COPY
qui copie le dossier node_modules/
de l’image Docker build dans cette nouvelle image de base de production.
De plus, vous voyez maintenant que NPM_TOKEN
est passé comme argument de build à l’image Docker intermédiaire de build
? Il n’est plus visible dans la sortie de la commande docker history nodejs-tutorial
car il n’existe pas dans notre image Docker de production.
9. Empêchez les fichiers inutiles d’apparaître dans vos images Docker Node.js
Le fichier .gitignore
permet d’éviter de polluer le référentiel git avec des fichiers inutiles, et potentiellement avec des fichiers sensibles aussi, n’est-ce pas ? Il en va de même pour les images Docker.
Qu’est-ce qu’un fichier Docker ignore ?
Docker dispose d’un fichier .dockerignore qui permet d’éviter d’envoyer au démon Docker toute correspondance de motifs globaux. La liste de fichiers suivante vous donne une idée de ce que vous pourriez inclure dans votre image Docker et qu’il faudrait idéalement éviter : - .dockerignore
-node_modules
-npm-debug.log
-Dockerfile
-.git
-.gitignore
Comme vous le voyez, il faut ignorer le dossier node_modules/
. En effet, dans le cas contraire, la version simpliste du Dockerfile avec laquelle nous avons commencé aurait provoqué la copie du dossier node_modules/
tel quel dans le conteneur.
FROM node:20.9.0-bullseye-slim
WORKDIR /usr/src/app
COPY . /usr/src/app
RUN npm install
CMD "npm" "start"
En fait, inclure un fichier .dockerignore
est indispensable si vous effectuez des builds Docker en plusieurs étapes. Pour rappel, voici à quoi ressemble la deuxième étape du build Docker :
__# --------------> The production image__
FROM node:20.9.0-bullseye-slim
ENV NODE_ENV production
COPY --from=build /usr/bin/dumb-init /usr/bin/dumb-init
USER node
WORKDIR /usr/src/app
COPY --chown=node:node --from=build /usr/src/app/node_modules /usr/src/app/node_modules
COPY --chown=node:node . /usr/src/app
CMD ["dumb-init", "node", "server.js"]
Voici pourquoi il est indispensable d’inclure un fichier .dockerignore
: faire un COPY . /usr/src/app
à partir de la deuxième étape du Dockerfile revient à copier également tous les node_modules/ locaux dans l’image Docker. C’est un gros problème, car cela peut entraîner la copie de code source modifié dans node_modules/
.
En outre, comme nous utilisons le joker COPY .
, il se peut également que des fichiers sensibles incluant des informations d’identification ou une configuration locale soient aussi copiés dans l’image Docker.
Les points à retenir ici pour un fichier .dockerignore
sont les suivants :
Ignore les copies potentiellement modifiées de
node_modules/
dans l’image Docker.Évite que des informations secrètes comme les identifiants contenues dans les fichiers
.env
ouaws.json
se retrouvent dans l’image Docker Node.js.Permet d’accélérer les builds Docker en ignorant les fichiers qui auraient sinon rendu le cache non valide. Par exemple, si un fichier journal ou un fichier de configuration de l’environnement local avait été modifié, cela aurait provoqué l’invalidation du cache de l’image Docker à ce niveau de la copie du répertoire local.
10. Montez des secrets dans l’image de build Docker
Le fichier .dockerignore
constitue une approche tout ou rien ; il ne peut être activé ou désactivé selon l’étape de build dans un build Docker en plusieurs étapes.
Pourquoi est-ce important ? Idéalement, nous voudrions utiliser le fichier .npmrc
dans l’étape du build ; nous pouvons en effet en avoir besoin puisqu’il inclut un jeton npm secret pour l’accès à des paquets npm privés. Il peut également avoir besoin d’une configuration de registre ou d’un proxy spécifique d’où extraire des paquets.
Il est donc logique d’inclure le fichier .npmrc
à l’étape du build
. Cependant, nous n’en avons pas besoin à la deuxième étape (pour l’image de production), et nous n’en voulons pas, car il est susceptible d’inclure des informations sensibles, comme le jeton npm secret.
L’une des solutions pour atténuer cet inconvénient lié à .dockerignore
consiste à monter un système de fichiers local qui sera disponible pour l’étape du build. Il existe toutefois une meilleure solution.
Docker prend en charge une fonctionnalité relativement récente, « Docker secrets », qui est en adéquation avec nos besoins pour .npmrc
. Voici comment cela fonctionne :
Pour exécuter la commande
docker build
, nous spécifions des arguments de ligne de commande qui définissent un nouvel ID secret et référencent un fichier comme source du secret.Dans le Dockerfile, nous ajoutons des indicateurs à la directive
RUN
afin d’installer la npm de production, qui monte le fichier référencé par l’ID secret dans l’emplacement cible (le fichier.npmrc
du répertoire local qui se trouve où nous voulons qu’il soit disponible).Le fichier
.npmrc
est monté comme un secret et n’est jamais copié dans l’image Docker.Enfin, n’oublions pas d’ajouter le fichier
.npmrc
au contenu du fichier.dockerignore
afin qu’il n’apparaisse dans aucune image, que ce soit les images de build ou de production.
Voyons comment tout cela fonctionne ensemble. D’abord le fichier .dockerignore
mis à jour :
.dockerignore
node_modules
npm-debug.log
Dockerfile
.git
.gitignore
.npmrc
Ensuite, le Dockerfile complet, avec la directive RUN mise à jour pour installer les paquets npm en spécifiant le point de montage .npmrc
:
__# --------------> The build image__
FROM node:latest AS build
RUN apt-get update && apt-get install -y --no-install-recommends dumb-init
WORKDIR /usr/src/app
COPY package*.json /usr/src/app/
RUN --mount=type=secret,mode=0644,id=npmrc,target=/usr/src/app/.npmrc npm ci --only=production
__# --------------> The production image__
FROM node:20.9.0-bullseye-slim
ENV NODE_ENV production
COPY --from=build /usr/bin/dumb-init /usr/bin/dumb-init
USER node
WORKDIR /usr/src/app
COPY --chown=node:node --from=build /usr/src/app/node_modules /usr/src/app/node_modules
COPY --chown=node:node . /usr/src/app
CMD ["dumb-init", "node", "server.js"]
Et enfin, la commande qui construit l’image Docker Node.js :
$ docker build . -t nodejs-tutorial --secret id=npmrc,src=.npmrc
Remarque : les secrets étant une nouvelle fonctionnalité de Docker, si vous utilisez une ancienne version, vous devrez peut-être l’activer dans le Buildkit comme suit :
$ DOCKER_BUILDKIT=1 docker build . -t nodejs-tutorial --build-arg NPM_TOKEN=1234 --secret id=npmrc,src=.npmrc
En résumé
Vous avez réussi à créer une image de base Docker Node.js optimisée. Excellent travail !
Cette dernière étape conclut ce guide sur la conteneurisation des applications web Docker Node.js, en tenant compte des optimisations liées aux performances et à la sécurité afin de garantir la création d’images Docker Node.js pouvant être déployées dans l’environnement de production.
Ressources complémentaires que je vous encourage vivement à consulter :
10 meilleures pratiques en matière de sécurité des images Docker
Docker pour les développeurs Java : les 5 choses à savoir pour une sécurité efficace
Meilleures pratiques pour conteneuriser des applications Python dans Docker
Une fois que vous avez construit des images de base Docker sécurisées et performantes pour vos applications Node.js, détectez et corrigez les vulnérabilités de vos conteneurs avec un compte Snyk gratuit.
La sécurité des conteneurs au service des développeurs
Snyk détecte et corrige automatiquement les vulnérabilités des images de conteneurs et des charges de travail Kubernetes.