Skip to main content

10 bonnes pratiques pour conteneuriser des applications web Node.js avec Docker

feature cheat sheet

15 septembre 2022

0 minutes de lecture

Vous cherchez les bonnes pratiques pour créer des images Docker Node.js pour vos applications web ? Vous êtes au bon endroit !

Cet article présente des recommandations de niveau production pour créer des images Docker Node.js optimisées et sécurisées. Elles vous seront utiles quel que soit le type d’application Node.js que vous souhaitez créer. Cet article vous sera utile si :

  • vous souhaitez créer une application frontend avec les fonctionnalités de rendu côté serveur (SSR) de Node.js pour React.

  • vous cherchez des conseils pour créer correctement une image Docker Node.js pour vos microservices exécutant Fastify, NestJS ou d’autres frameworks d’application.

Pourquoi avons-nous rédigé ce guide sur la conteneurisation des applications web Node.js avec Docker ?

Cela peut sembler être un énième article expliquant comment créer des images Docker pour des applications Node.js, mais bon nombre d’exemples que nous avons vus sur des blogs sont très simplistes. Ils se limitent à vous montrer comment exécuter une application dans une image Docker Node.js, sans tenir compte de la sécurité ni des bonnes pratiques de création d’images Docker Node.js.

Nous allons apprendre à conteneuriser des applications web Node.js étape par étape, en commençant par un Dockerfile simple et fonctionnel, puis en examinant les pièges et les failles de chaque instruction avant de les corriger.

Une image Docker Node.js simple

La plupart des articles de blog que nous avons consultés se contentent de présenter les 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 contenu dans un fichier nommé Dockerfile, puis créez et exécutez l’image.

$ docker build . -t nodejs-tutorial
$ docker run -p 3000:3000 nodejs-tutorial

C’est simple, et ça fonctionne.

Le seul problème ? Ce fichier comporte de nombreuses erreurs et mauvaises pratiques pour la création d’images Docker Node.js. Évitez absolument ces erreurs et ces mauvaises pratiques.

Commençons à améliorer ce Dockerfile afin de créer des applications web Node.js optimisées avec Docker.

Vous pouvez suivre ce tutoriel en clonant ce dépôt.

Suivez ces 10 étapes pour créer des applications web Node.js optimisées avec Docker :

  1. Utilisez des tags d’image Docker de base explicites et déterministes

  2. Installez uniquement les dépendances de production dans l’image Docker Node.js

  3. Optimisez les outils Node.js pour la production

  4. N’exécutez pas les conteneurs en tant que root

  5. Arrêtez les applications web Docker Node.js en toute sécurité

  6. Arrêt progressif de vos applications web Node.js

  7. Détectez et corrigez les vulnérabilités de sécurité dans votre image Docker Node.js

  8. Utilisez des builds multi-étapes

  9. Évitez d’inclure des fichiers superflus dans vos images Docker Node.js

  10. Montez les secrets dans l’image de build Docker

1. Utilisez des tags d’image Docker de base explicites et déterministes

Utiliser l’image Docker node pour créer votre image peut sembler évident, mais que récupérez-vous réellement lors de sa création ? Les images Docker sont toujours référencées par des tags. Si vous n’en spécifiez aucun, le tag par défaut :latest est utilisé.

En spécifiant ce qui suit dans votre Dockerfile, vous créez donc toujours la dernière version de l’image Docker publiée par le groupe de travail Docker Node.js :

FROM node

La création d’une image à partir de l’image node par défaut présente les inconvénients suivants :

  1. Les builds d’images Docker ne sont pas cohérents. Tout comme nous utilisons des lockfiles pour garantir un comportement déterministe de npm install à chaque installation de packages npm, nous voulons aussi que les builds d’images Docker soient déterministes. Si nous créons l’image à partir de node — ce qui revient à utiliser le tag node:latest — chaque build récupère une nouvelle image Docker de node. Nous voulons éviter ce type de comportement non déterministe.

  2. L’image Docker node repose sur un système d’exploitation complet, rempli de bibliothèques et d’outils dont vous n’aurez peut-être pas besoin pour exécuter votre application web Node.js. Cela présente deux inconvénients. Premièrement, une image plus volumineuse entraîne des téléchargements plus longs et augmente les besoins de stockage, ainsi que le temps nécessaire pour télécharger et recréer l’image. Deuxièmement, toutes ces bibliothèques et tous ces outils peuvent introduire dans l’image des vulnérabilités de sécurité.

En réalité, l’image Docker node est assez volumineuse et contient des centaines de vulnérabilités de sécurité de différents types et niveaux de gravité. Si vous l’utilisez, vous partez par défaut d’un socle comportant 642 vulnérabilités, ainsi que de centaines de mégaoctets de données d’image à télécharger à chaque récupération et à chaque build.

Diagramme à barres comparant les vulnérabilités des images de conteneurs officielles en 2018 et 2019 pour node, postgres, nginx, httpd, mongo, mysql, couchbase, memcached et

Voici nos recommandations pour créer de meilleures images Docker :

  1. Utilisez de petites images Docker : leur empreinte logicielle réduite diminue le nombre de vecteurs de vulnérabilité potentiels. Leur taille plus compacte accélère également le processus de création de l’image.

  2. Utilisez le digest de l’image Docker, son empreinte SHA256 statique. Vous obtiendrez ainsi des builds d’images Docker déterministes à partir de l’image de base.

Nous avons publié un article complet expliquant comment choisir la meilleure image Node.js Docker. Il détaille pourquoi une distribution Debian slim à jour, associée à une version du runtime Node.js bénéficiant d’un support à long terme, constitue le choix idéal.

Voici l’image Docker Node.js que nous recommandons :

FROM node:20.9.0-bullseye-slim

Ce tag d’image Docker Node.js utilise une version précise du runtime Node.js (20.9.0), correspondant à la dernière version actuelle avec support à long terme. Il utilise la variante d’image bullseye, la version stable actuelle de Debian 11, dont la fin de vie est encore suffisamment éloignée. Enfin, la variante d’image slim réduit l’empreinte logicielle du système d’exploitation : l’image fait ainsi moins de 200 Mo, runtime et outils Node.js inclus.

Cela dit, l’une des pratiques courantes, mais malavisées, que vous rencontrerez dans les tutoriels et les guides consiste à utiliser l’instruction Docker suivante pour l’image de base :

FROM node:alpine

Ces articles recommandent l’image Docker Node.js Alpine, mais est-ce vraiment le choix idéal ? Cette recommandation repose principalement sur l’empreinte logicielle réduite de l’image Docker Node.js Alpine. Pourtant, celle-ci diffère considérablement sur d’autres aspects, ce qui en fait une image de base sous-optimale pour les runtimes d’applications Node.js en production.

Qu’est-ce que Node Alpine ?

Node.js Alpine est une image de conteneur Docker non officielle, créée et maintenue par l’équipe Docker Node.js. L’image Node.js intègre le système d’exploitation Alpine, qui repose sur les outils logiciels minimalistes busybox et l’implémentation de la bibliothèque C musl. Ces deux caractéristiques expliquent pourquoi l’équipe Node.js ne prend pas officiellement en charge l’image Node.js Alpine. De plus, de nombreux scanners de vulnérabilités ne détectent pas facilement les artefacts logiciels ou les runtimes présents dans les images Node.js Alpine, ce qui va à l’encontre des efforts visant à sécuriser vos images de conteneurs.

Même si vous utilisez le tag d’image Node.js Alpine, une instruction d’image de base sous la forme d’un alias textuel peut tout de même récupérer de nouvelles versions de ce tag, car les tags d’image Docker sont modifiables. Vous pouvez trouver son hash SHA256 sur le Docker Hub pour ce tag Node.js, ou exécuter la commande suivante après avoir récupéré l’image localement, puis repérer le champ Digest dans le résultat :

$ 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

Vous pouvez également trouver le hash SHA256 en exécutant 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"

Cependant, le Dockerfile ci-dessus indique uniquement le nom de l’image Docker Node.js, sans tag. Il est donc impossible de savoir précisément quel tag d’image est utilisé. Cette approche est difficile à lire et à maintenir, et offre une mauvaise expérience de développement.

Corrigeons cela en mettant à jour le Dockerfile et en indiquant le tag complet de l’image de base pour la version de Node.js correspondant à ce hash SHA256 :

FROM node:20.9.0-bullseye-slim@sha256:330fa0342b6ad2cbdab30ac44195660af5a1f298cc499d8cbdf7496b02ea17d8
WORKDIR /usr/src/app
COPY . /usr/src/app
RUN npm install
CMD "npm" "start"

Le digest de l’image Docker garantit un résultat déterministe, mais peut prêter à confusion ou compliquer le travail de certains outils d’analyse d’images qui ne savent pas l’interpréter. C’est pourquoi il est préférable d’utiliser une version explicite du runtime Node.js, telle que 20.9.0. Même si, en théorie, cette version est modifiable et peut être remplacée, en pratique, les mises à jour de sécurité ou autres seront publiées dans une nouvelle version, telle que 20.9.1. Vous pouvez donc considérer que les builds seront suffisamment déterministes.

Voici donc le Dockerfile que nous recommandons à ce stade :

FROM node:16.17.0-bullseye-slim
WORKDIR /usr/src/app
COPY . /usr/src/app
RUN npm install
CMD "npm" "start"

Découvrez d’autres conseils et bonnes pratiques pour créer des images de conteneur sécurisées.

2. Installez uniquement les dépendances de production dans l’image Docker Node.js

L’instruction Dockerfile suivante installe toutes les dépendances dans le conteneur, y compris les devDependencies, qui ne sont pas nécessaires au fonctionnement de l’application. Elle augmente inutilement la taille de l’image et les risques de sécurité liés aux packages utilisés comme dépendances de développement.

RUN npm install

Si vous avez suivi mon guide précédent sur les 10 bonnes pratiques de sécurité npm, vous savez qu’il faut garantir des builds déterministes avec npm ci. Cela évite les mauvaises surprises dans un processus d’intégration continue (CI), car la commande s’interrompt si le fichier de verrouillage a été modifié.

Pour créer une image Docker destinée à la production, nous voulons nous assurer de n’installer que les dépendances de production, de manière déterministe. Voici donc la bonne pratique à suivre pour installer les dépendances npm dans une image de conteneur :

RUN npm ci --only=production

À cette étape, le Dockerfile mis à jour se présente comme suit :

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 les outils Node.js pour la production

Lorsque vous créez votre image Docker Node.js pour la production, assurez-vous que tous les frameworks et bibliothèques utilisent les paramètres optimaux en matière de performances et de sécurité.

Nous devons donc ajouter l’instruction Dockerfile suivante :

ENV NODE_ENV production

À première vue, cela semble redondant, puisque nous avons déjà indiqué de n’installer que les dépendances de production lors de l’étape npm install. Alors, pourquoi cette instruction est-elle nécessaire ?

Les développeurs associent souvent la définition de la variable d’environnement NODE_ENV=production à l’installation des dépendances de production. Pourtant, ce paramètre a aussi d’autres effets qu’il est important de connaître.

Certains frameworks et bibliothèques n’activent leur configuration optimisée pour la production que si la variable d’environnement NODE_ENV est définie sur production. Que cette pratique soit bonne ou mauvaise pour les frameworks, il est important de le savoir.

Par exemple, la documentation Express explique combien il est important de définir cette variable d’environnement pour activer les optimisations de performances et de sécurité :

Documentation Express mettant en avant les avantages de NODE_ENV="production", notamment la mise en cache des modèles et du CSS, ainsi que des messages d’erreur moins détaillés.

L’impact de la variable NODE_ENV sur les performances peut être considérable.

L’équipe de Dynatrace a publié un article détaillant les conséquences majeures de l’absence de NODE_ENV dans vos applications Express.

De nombreuses autres bibliothèques dont vous dépendez peuvent également s’attendre à ce que cette variable soit définie. Nous devons donc l’ajouter à notre Dockerfile.

La variable d’environnement NODE_ENV étant désormais intégrée, le Dockerfile mis à jour devrait se présenter ainsi :

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 débuts d’Unix. Nous devons toujours le respecter lorsque nous exécutons nos applications web Node.js conteneurisées.

L’évaluation des menaces est assez simple : si un attaquant parvient à compromettre l’application web au moyen d’une injection de commandes ou d’un traversée de répertoires, les commandes seront exécutées avec les droits de l’utilisateur propriétaire du processus de l’application. Si ce processus s’exécute en tant que root, l’attaquant peut pratiquement tout faire dans le conteneur, y compris tenter d’en sortir ou élever ses privilèges. Pourquoi prendre ce risque ? Vous avez raison, nous ne le voulons 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, inclut un utilisateur aux privilèges minimaux du même nom : node. Cependant, il ne suffit pas d’exécuter le processus en tant que node. Par exemple, la configuration suivante n’est pas idéale pour le fonctionnement de l’application :

USER node
CMD "npm" "start"

La raison, c’est que la directive USER du Dockerfile garantit uniquement que le processus appartient à l’utilisateur node. Et tous les fichiers que nous avons copiés auparavant avec l’instruction COPY ? Ils appartiennent à root. C’est le fonctionnement par défaut de Docker.

Voici la méthode complète et correcte pour supprimer les privilèges. Elle présente également les bonnes pratiques Dockerfile à jour que nous avons mises en place jusqu’ici :

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. Arrêter en toute sécurité les applications web Node.js dans Docker

L’une des erreurs les plus courantes que je rencontre dans les blogs et articles sur la conteneurisation des applications Node.js exécutées dans des conteneurs Docker concerne la façon dont le processus est lancé. Tous les exemples suivants, ainsi que leurs variantes, sont de mauvaises pratiques à éviter :

  • CMD “npm” “start”

  • CMD [“yarn”, “start”]

  • CMD “node” “server.js”

  • CMD “start-app.sh”

Voyons cela de plus près ! Je vais passer en revue chacune de ces façons de lancer le processus et vous expliquer pourquoi les éviter.

Pour bien comprendre comment exécuter et arrêter les applications Node.js dans Docker, il est essentiel de tenir compte des éléments suivants :

  1. Un moteur d’orchestration, comme Docker Swarm, Kubernetes ou même le moteur Docker lui-même, doit pouvoir envoyer des signaux au processus dans le conteneur. Il s’agit le plus souvent de signaux d’arrêt d’une application, comme SIGTERM et SIGKILL.

  2. Le processus peut être exécuté indirectement. Dans ce cas, il n’est pas toujours certain qu’il reçoive ces signaux.

  3. Le noyau Linux traite différemment les processus exécutés avec l’identifiant de processus 1 (PID) et ceux ayant tout autre identifiant.

Maintenant que nous avons ces éléments en tête, examinons les différentes façons de lancer le processus d’un conteneur, en commençant par l’exemple du Dockerfile que nous construisons :

CMD "npm" "start"

Le problème est double. Premièrement, nous exécutons indirectement l’application Node en lançant directement le client npm. Qui peut garantir que la CLI npm transmet tous les événements au runtime Node ? En réalité, ce n’est pas le cas, et nous pouvons facilement le vérifier.

Dans votre application Node.js, configurez un gestionnaire d’événements pour le signal SIGHUP, qui écrit un message dans la console chaque fois qu’un événement est envoyé. Voici un exemple de code simple :

function handle(signal) {
   console.log(`*^!@4=> Received event: ${signal}`)
}
process.on('SIGHUP', handle)

Lancez ensuite le conteneur et, une fois qu’il est opérationnel, envoyez-lui le signal SIGHUP à l’aide de la CLI docker et de l’option de ligne de commande spéciale --signal :

$ docker kill --signal=SIGHUP elastic_archimedes

Rien ne s’est passé, n’est-ce pas ? C’est parce que le client npm ne transmet aucun signal au processus Node qu’il a lancé.

L’autre problème concerne les différentes façons de spécifier la directive CMD dans le Dockerfile. Il en existe deux, et elles ne sont pas équivalentes :

  1. La syntaxe shell, dans laquelle le conteneur lance un interpréteur de commandes qui encapsule le processus. Dans ce cas, le shell risque de ne pas transmettre correctement les signaux à votre processus.

  2. La syntaxe exec, qui lance directement un processus sans l’encapsuler dans un shell. Elle utilise la notation de tableau JSON, par exemple : CMD [“npm”, “start”]. Tous les signaux envoyés au conteneur sont transmis directement au processus.

En tenant compte de ces éléments, nous allons modifier la directive d’exécution du processus dans notre Dockerfile comme suit :

CMD ["node", "server.js"]

Nous lançons maintenant directement le processus Node, ce qui garantit qu’il reçoit tous les signaux qui lui sont envoyés, sans être encapsulé dans un interpréteur de commandes.

Cela introduit toutefois un autre écueil.

Les processus exécutés avec le PID 1 assument de fait certaines responsabilités d’un système init, généralement chargé d’initialiser un système d’exploitation et ses processus. Le noyau traite le PID 1 différemment des autres identifiants de processus. En conséquence, lorsqu’un processus en cours d’exécution reçoit un signal SIGTERM, le comportement de repli par défaut — qui consiste à tuer le processus — n’est pas déclenché si celui-ci n’a pas défini de gestionnaire pour ce signal.

Pour reprendre la recommandation du groupe de travail Node.js Docker à ce sujet : « Node.js n’a pas été conçu pour s’exécuter avec le PID 1, ce qui entraîne un comportement inattendu dans Docker. Par exemple, un processus Node.js exécuté avec le PID 1 ne répondra pas à SIGINT (CTRL-C) ni aux signaux similaires. »

La solution consiste à utiliser un outil qui joue le rôle d’un processus init : il est lancé avec le PID 1, puis démarre notre application Node.js comme un autre processus, tout en veillant à lui transmettre tous les signaux. Dans la mesure du possible, nous voulons que cet outil soit le plus léger possible, afin d’éviter d’ajouter des vulnérabilités de sécurité à notre image de conteneur.

Chez Snyk, nous utilisons notamment dumb-init, un outil lié statiquement et peu encombrant. Voici comment le configurer :

RUN apt-get update && apt-get install -y --no-install-recommends dumb-init
CMD ["dumb-init", "node", "server.js"]

Nous obtenons ainsi le Dockerfile à jour suivant. Vous remarquerez que nous avons placé l’installation du paquet dumb-init juste après la déclaration de l’image, afin de tirer parti 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 avec RUN apt-get update && apt-get install, certaines informations restent dans l’image Docker. Pour nettoyer après cette commande et obtenir une image plus légère, nous pouvons la compléter ainsi :

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"]

Nous pouvons encore améliorer cette configuration en utilisant ENTRYPOINT dans le Dockerfile pour définir dumb-init et lui fournir le processus Node.js à exécuter comme argument de ligne de commande. Pour cela, modifiez l’entrée correspondante dans le Dockerfile comme suit :

FROM node:16.17.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
ENTRYPOINT ["/usr/bin/dumb-init", "--"]
CMD ["node", "server.js"]

Conseil : il est encore préférable d’installer l’outil dumb-init dans une étape de build antérieure, puis de copier le fichier obtenu /usr/bin/dumb-init dans l’image finale du conteneur afin de la garder propre. Nous en apprendrons davantage sur les builds Docker à plusieurs étapes plus loin dans ce guide.

Bon à savoir : les commandes docker kill et docker stop envoient des signaux uniquement au processus du conteneur ayant le PID 1. Si vous exécutez un script shell qui lance votre application Node.js, sachez qu’une instance du shell — comme /bin/sh, par exemple — ne transmet pas les signaux aux processus enfants. Votre application ne recevra donc jamais de SIGTERM.

6. Arrêter en douceur vos applications web Node.js

Puisque nous parlons des signaux qui arrêtent les applications, veillons à ce qu’elles s’arrêtent correctement et en douceur, sans perturber les utilisateurs.

Lorsqu’une application Node.js reçoit un signal d’interruption, également appelé SIGINT ou CTRL+C, le processus s’arrête brutalement, sauf si un gestionnaire d’événements est configuré pour adopter un autre comportement. Les clients connectés à une application web sont alors immédiatement déconnectés. Imaginez maintenant des centaines de conteneurs web Node.js orchestrés par Kubernetes, qui démarrent et s’arrêtent selon les besoins de mise à l’échelle ou de gestion des erreurs. L’expérience utilisateur risque d’être loin d’être optimale.

Vous pouvez facilement simuler ce problème. Voici un exemple d’application web Fastify standard, dont un point de terminaison renvoie une réponse après un délai de 60 secondes :

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()

Lancez cette application puis, une fois qu’elle est en cours d’exécution, envoyez une simple requête HTTP à ce point de terminaison :

$ time curl https://localhost:3000/delayed

Appuyez sur CTRL+C dans la fenêtre de console Node.js en cours d’exécution : vous verrez que la requête curl s’interrompt brutalement. C’est ce que vos utilisateurs vivraient lors de l’arrêt des conteneurs.

Pour offrir une meilleure expérience, nous pouvons procéder comme suit :

  1. Configurer un gestionnaire d’événements pour les différents signaux d’arrêt, comme SIGINT et SIGTERM.

  2. Le gestionnaire attend la fin des opérations de nettoyage, comme la fermeture des connexions à la base de données et le traitement des requêtes HTTP en cours.

  3. Le gestionnaire arrête ensuite le processus Node.js.

Avec Fastify, nous pouvons notamment appeler fastify.close() dans notre gestionnaire. Cette méthode renvoie une promesse que nous pouvons attendre. Fastify veille également à 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 enjeu général des applications web que d’un sujet propre au Dockerfile, mais c’est d’autant plus important dans les environnements orchestrés.

7. Détecter et corriger les vulnérabilités de sécurité dans votre image Docker Node.js

Vous vous souvenez de l’importance d’utiliser des images Docker de base légères pour nos applications Node.js ? Mettons cela en pratique.

Je vais utiliser la CLI Snyk pour tester notre image Docker. Vous pouvez créer gratuitement un compte Snyk 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 Snyk. Elle est suivie d’une rapide procédure de connexion en ligne de commande pour récupérer une clé API. Nous pouvons ensuite analyser le conteneur afin d’y 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 l’exécutable du runtime Node.js, et n’a trouvé aucune version vulnérable du runtime. Cependant, certains logiciels de l’image du conteneur présentent 44 vulnérabilités de sécurité. Parmi ces dépendances, 43 présentent des problèmes de faible gravité, et une présente 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

Corriger les vulnérabilités des images Docker

Pour maintenir rapidement et efficacement la sécurité des logiciels dans votre image Docker, vous pouvez reconstruire celle-ci. L’image Docker de base en amont que vous utilisez se chargera alors de récupérer les mises à jour. Vous pouvez aussi installer explicitement les mises à jour du système d’exploitation pour les paquets, y compris les correctifs de sécurité.

Avec l’image Docker officielle Node.js, l’équipe peut mettre du temps à publier une mise à jour de l’image. Reconstruire l’image Docker Node.js 20.9.0-bullseye-slim ou lts-bullseye-slim ne sera donc pas efficace. L’autre option consiste à gérer votre propre image de base avec des logiciels Debian à jour. Dans notre Dockerfile, nous pouvons procéder comme suit :

RUN apt-get update && apt-get upgrade -y

Lançons l’analyse de sécurité Snyk après avoir construit l’image Docker Node.js avec cette nouvelle instruction RUN :

✗ 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

Une dépendance système supplémentaire a été ajoutée (98 au lieu de 97), mais les 43 vulnérabilités de sécurité qui affectent cette image Docker Node.js sont désormais toutes de faible gravité. Nous avons également corrigé la vulnérabilité critique de sécurité de zlib. C’est une belle victoire !

Que se passerait-il si nous avions utilisé la directive d’image de base FROM node ? Mieux encore, imaginons que vous ayez 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

À première vue, une version précise du runtime Node.js, comme FROM node:14.2.0-slim, semble suffire : vous avez indiqué une version spécifique (14.2.0) et choisi une petite image de conteneur (grâce à l’étiquette slim). Pourtant, Snyk peut détecter des vulnérabilités de sécurité provenant de deux sources principales :

  1. Le runtime Node.js lui-même : avez-vous remarqué les deux principales vulnérabilités de sécurité dans le rapport ci-dessus ? Il s’agit de problèmes de sécurité publiquement connus dans le runtime Node.js. La solution immédiate consiste à passer à une version plus récente de Node.js. Snyk vous indique également quelle version les corrige — 14.11.0, comme vous pouvez le voir dans le résultat.

  2. Les outils et bibliothèques installés dans cette image de base Debian, comme glibc, bzip2, gcc, perl, bash, tar, libcrypt et d’autres. Ces versions vulnérables dans le conteneur ne représentent peut-être pas une menace immédiate, mais pourquoi les conserver si nous ne les utilisons pas ?

L’atout majeur de ce rapport de la CLI Snyk ? Snyk recommande également d’autres images de base vers lesquelles passer : vous n’avez pas à chercher vous-même. Trouver des images de remplacement peut prendre beaucoup de temps ; Snyk vous épargne cette tâche.

À ce stade, je vous recommande de procéder comme suit :

  1. 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. L’interface Snyk vous proposera également des recommandations et surveillera vos images Docker en continu pour détecter toute nouvelle vulnérabilité de sécurité.

  2. Utilisez la CLI Snyk dans votre automatisation CI. Très flexible, elle a été créée précisément pour s’adapter à tous vos workflows personnalisés. Nous proposons également Snyk for GitHub Actions, si cela vous intéresse.

Pour découvrir d’autres façons de gérer les vulnérabilités dans les images de conteneurs, consultez notre guide sur la sécurité des conteneurs.

8. Utilisez des builds multi-étapes

Les builds multi-étapes permettent de passer d’un Dockerfile simple, mais potentiellement source d’erreurs, à des étapes distinctes pour créer une image Docker et éviter ainsi toute fuite d’informations sensibles. De plus, nous pouvons utiliser une image de base Docker plus volumineuse pour installer nos dépendances et compiler les packages npm natifs, si nécessaire, puis copier tous ces artefacts dans une petite image de base de production, comme notre exemple basé sur Alpine.

Évitez les fuites d’informations sensibles

Le cas d’usage consistant à éviter les fuites d’informations sensibles est plus fréquent que vous ne le pensez.

Si vous créez des images Docker dans le cadre de votre travail, il y a de fortes chances que vous gériez également des packages npm privés. Dans ce cas, vous avez probablement dû trouver un moyen de rendre ce secret NPM_TOKEN accessible à npm install.

Voici un exemple de ce que je veux dire :

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"]

Cette méthode laisse toutefois le fichier .npmrc et le jeton npm secret qu’il contient dans l’image Docker. Vous pourriez essayer d’améliorer la situation en le supprimant ensuite, comme ceci :

RUN echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > .npmrc && \
   npm ci --only=production
RUN rm -rf .npmrc

Cependant, le fichier .npmrc est maintenant disponible dans une autre couche de l’image Docker. Si cette image Docker est publique ou si quelqu’un parvient à y accéder, votre jeton est compromis. Voici une meilleure solution :

RUN echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > .npmrc && \
   npm ci --only=production; \
   rm -rf .npmrc

Le problème, maintenant, c’est que le Dockerfile lui-même doit être traité comme un élément secret, car il contient le jeton npm secret.

Heureusement, Docker permet de transmettre des arguments au processus de build :

ARG NPM_TOKEN
RUN echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > .npmrc && \
   npm ci --only=production; \
   rm -rf .npmrc

Ensuite, nous lançons le build comme suit :

$ docker build . -t nodejs-tutorial --build-arg NPM_TOKEN=1234

Je sais que vous pensiez que tout était réglé à ce stade, mais je suis désolé de vous décevoir.

C’est ainsi avec la sécurité : les évidences peuvent parfois se révéler être de nouveaux pièges.

Quel est le problème, vous demandez-vous ? Les arguments de build transmis ainsi à Docker sont conservés dans l’historique. Voyons-le de nos propres yeux. Exécutez cette commande :

$ docker history nodejs-tutorial

qui affiche le résultat suivant :

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

Vous avez repéré le jeton npm secret ? Voilà ce que je veux dire.

Il existe une excellente façon de gérer les secrets pour l’image de conteneur, mais c’est l’occasion de présenter les builds multi-étapes comme solution à ce problème, et de montrer comment créer des images minimales.

Présentation des builds multi-étapes pour les images Docker Node.js

Comme pour le principe de séparation des responsabilités en développement logiciel, nous appliquerons la même idée pour créer nos images Docker Node.js. Nous aurons une image dédiée à la création de tout ce dont l’application Node.js a besoin pour fonctionner, c’est-à-dire, dans l’écosystème Node.js, l’installation des packages npm et, si nécessaire, la compilation des modules npm natifs. Ce sera notre première étape.

La deuxième image Docker, qui correspond à la deuxième étape du build Docker, sera l’image Docker de production. Cette dernière étape est celle que nous optimisons réellement et publions dans un registre, le cas échéant. La première image, que nous appellerons l’image build, est abandonnée et reste une image orpheline sur l’hôte Docker qui l’a créée, jusqu’à son nettoyage.

Voici la version actualisée de notre Dockerfile, qui reflète notre progression et se compose de 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 volumineuse pour l’étape build, car je pourrais avoir besoin d’outils comme gcc(GNU Compiler Collection) pour compiler des packages npm natifs, ou pour d’autres tâches.

Dans la deuxième étape, une notation spéciale de la directive COPY permet de copier le dossier node_modules/ de l’image Docker de build dans cette nouvelle image de base de production.

Vous voyez aussi que NPM_TOKEN est transmis comme argument de build à l’image Docker intermédiaire build ? Il n’apparaît plus dans la sortie de la commande docker history nodejs-tutorial, car il n’existe pas dans notre image Docker de production.

9. Évitez les fichiers inutiles dans vos images Docker Node.js

Vous avez un fichier .gitignore pour éviter d’encombrer votre dépôt Git avec des fichiers inutiles, voire sensibles, n’est-ce pas ? Le même principe s’applique aux images Docker.

Qu’est-ce qu’un fichier d’exclusion Docker ?

Docker dispose du fichier .dockerignore, qui lui permet d’ignorer les fichiers correspondant aux motifs glob qu’il contient lors de leur envoi au daemon Docker. Voici une liste de fichiers qui vous donnera une idée de ce que vous pourriez inclure dans votre image Docker et qu’il vaut mieux éviter :- .dockerignore- node_modules- npm-debug.log- Dockerfile- .git- .gitignore

Comme vous pouvez le voir, il est particulièrement important d’exclure node_modules/ : si nous ne l’avions pas fait, la version simpliste du Dockerfile que nous avons utilisée au départ aurait copié tel quel le dossier local node_modules/ 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, un fichier .dockerignore est encore plus important lorsque vous utilisez des builds Docker multi-étapes. Pour vous rafraîchir la mémoire, 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"]

L’intérêt d’avoir un fichier .dockerignore, c’est que lorsque nous utilisons COPY . /usr/src/app dans la deuxième étape du Dockerfile, nous copions également tout éventuel dossier node_modules/ local dans l’image Docker. C’est à éviter absolument, car nous pourrions copier du code source modifié dans node_modules/.

De plus, avec le caractère générique COPY ., nous risquons également de copier dans l’image Docker des fichiers sensibles contenant des identifiants ou des configurations locales.

En résumé, un fichier .dockerignore permet de :

  • Éviter d’inclure dans l’image Docker des copies potentiellement modifiées de node_modules/.

  • Prévenir les fuites de secrets, par exemple lorsque des identifiants contenus dans des fichiers .env ou aws.json se retrouvent dans l’image Docker Node.js.

  • Accélérer les builds Docker en ignorant les fichiers qui auraient autrement invalidé le cache. Par exemple, la modification d’un fichier journal ou d’un fichier de configuration d’environnement local aurait invalidé le cache de l’image Docker au niveau de la copie du répertoire local.

10. Monter des secrets dans l’image de build Docker

Notez que le fichier .dockerignore fonctionne selon une approche tout ou rien : il n’est pas possible de l’activer ou de le désactiver pour chaque étape d’un build Docker multi-étapes.

Pourquoi est-ce important ? Idéalement, nous voudrions utiliser le fichier .npmrc pendant l’étape de build, car nous pouvons en avoir besoin pour accéder aux packages npm privés à l’aide d’un jeton npm secret. Il peut aussi contenir une configuration de proxy ou de registre spécifique nécessaire à la récupération des packages.

Il est donc logique de rendre le fichier .npmrc accessible à l’étape build. En revanche, nous n’en avons pas besoin dans la deuxième étape, celle de l’image de production, et nous ne voulons pas qu’il s’y trouve, car il peut contenir des informations sensibles comme le jeton npm secret.

Pour contourner cette limite de .dockerignore, vous pouvez monter un système de fichiers local accessible pendant l’étape de build, mais il existe une meilleure méthode.

Docker prend en charge une fonctionnalité relativement récente appelée Docker secrets, qui convient parfaitement à notre cas d’usage avec .npmrc. Voici comment procéder :

  • Lors de l’exécution de la commande docker build, nous spécifions des arguments de ligne de commande qui définissent un nouvel ID de secret et indiquent un fichier comme source du secret.

  • Dans le Dockerfile, nous ajoutons des options à la directive RUN pour installer les packages npm de production. Le fichier référencé par l’ID du secret est ainsi monté à l’emplacement cible : le fichier local .npmrc, là où nous en avons besoin.

  • Le fichier .npmrc est monté en tant que secret et n’est jamais copié dans l’image Docker.

  • Enfin, n’oubliez pas d’ajouter le fichier .npmrc à la liste des fichiers du fichier .dockerignore, afin qu’il ne soit inclus ni dans l’image de build ni dans celle de production.

Voyons comment tout cela fonctionne ensemble. Tout 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 packages npm et indiquer 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 crée l’image Docker Node.js :

$ docker build . -t nodejs-tutorial --secret id=npmrc,src=.npmrc

Remarque : Les secrets sont une nouvelle fonctionnalité de Docker. Si vous utilisez une version antérieure, vous devrez peut-être activer Buildkit comme suit :

$ DOCKER_BUILDKIT=1 docker build . -t nodejs-tutorial --build-arg NPM_TOKEN=1234 --secret id=npmrc,src=.npmrc

Résumé

Vous avez réussi à créer une image de base Docker Node.js optimisée. Bravo !

Cette dernière étape conclut notre guide complet sur la conteneurisation des applications web Node.js avec Docker. Nous avons pris en compte les optimisations de performances et de sécurité pour créer des images Docker Node.js prêtes pour la production !

Voici quelques ressources complémentaires que je vous recommande vivement de consulter :

Une fois que vous avez créé des images Docker de base 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, 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.