Skip to main content

10 meilleures pratiques pour conteneuriser des applications Web Node.js avec Docker

wordpress-sync/feature-node.js-cheat-sheet

15 septembre 2022

0 minutes de lecture

Note 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.

wordpress-sync/NodeJS-cheat-sheet

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 :

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

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

  3. Optimisez l’outillage de production de Node.js

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

  5. Quittez en toute sécurité les applications web Docker Node.js

  6. Quittez normalement 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 en plusieurs étapes

  9. Empêchez les fichiers inutiles d’apparaître dans vos images Docker Node.js

  10. 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 :

  1. 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 de npm 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 tag node:latest), à chaque build, une nouvelle image Docker sera extraite pour node. Il n’est pas souhaitable d’introduire ce type de comportement non déterministe.

  2. 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.

wordpress-sync/blog-container-image-vulnerabilities

Voici quelques recommandations pour construire des images Docker de meilleure qualité :

  1. 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.

  2. 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é :

wordpress-sync/blog-snyk-docker-optimize-node.js-tooling

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 :

  1. 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 et SIGKILL.

  2. Le processus peut s’exécuter indirectement, et dans ce cas, il n’est pas toujours garanti qu’il recevra ces signaux.

  3. 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 :

  1. 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.

  2. 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 :

  1. Définissez un gestionnaire d’événements pour la gestion des signaux de terminaison tels que SIGINT et SIGTERM.

  2. 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.

  3. 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 :

  1. 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).

  2. 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 :

  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 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.

  2. 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 ou aws.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 :

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.