Aller au contenu
·19 min de lecture

Docker pour Node.js : la configuration production-ready dont personne ne parle

Builds multi-étapes, utilisateurs non-root, health checks, gestion des secrets et optimisation de la taille des images. Les patterns Docker que j'utilise pour chaque déploiement Node.js en production.

Partager:X / TwitterLinkedIn

La plupart des Dockerfiles Node.js en production sont mauvais. Pas « légèrement sous-optimaux ». Je parle de conteneurs tournant en root, d'images de 600 Mo avec les devDependencies intégrées, sans health checks, et avec des secrets codés en dur dans des variables d'environnement que n'importe qui peut lire avec docker inspect.

Je le sais parce que j'ai écrit ces Dockerfiles. Pendant des années. Ils fonctionnaient, donc je ne les ai jamais remis en question. Puis un jour, un audit de sécurité a signalé notre conteneur tournant en PID 1 root avec un accès en écriture à l'intégralité du système de fichiers, et j'ai réalisé que « ça marche » et « prêt pour la production » sont des barres très différentes.

Voici la configuration Docker que j'utilise désormais pour chaque projet Node.js. Ce n'est pas théorique. Elle fait tourner les services derrière ce site et plusieurs autres que je maintiens. Chaque pattern existe parce que j'ai été brûlé par l'alternative, ou parce que j'ai vu quelqu'un d'autre se faire brûler.

Pourquoi votre Dockerfile actuel est probablement mauvais#

Je parie que votre Dockerfile ressemble à ça :

dockerfile
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
EXPOSE 3000
CMD ["node", "server.js"]

C'est le « hello world » des Dockerfiles. Ça fonctionne. Mais il comporte au moins cinq problèmes qui vous feront mal en production.

Exécution en root#

Par défaut, les conteneurs Docker tournent en root. Si un attaquant exploite une vulnérabilité dans votre application Node.js, il obtient un accès root au conteneur. Avec certaines mauvaises configurations Docker (et il y en a beaucoup dans la nature), cela peut signifier un accès root à la machine hôte.

Images énormes#

L'image node:20 fait environ 350 Mo. Ajoutez-y vos node_modules (y compris les devDependencies), vos fichiers source, les fichiers de test et la documentation, et vous arrivez facilement à 600 Mo et plus. Les images plus grandes signifient des déploiements plus lents, un stockage accru et une surface d'attaque élargie — plus il y a de logiciels dans votre conteneur, plus il y a de vulnérabilités potentielles.

Pas de cache de couche#

COPY . . avant RUN npm install signifie que chaque modification de fichier invalide le cache de npm install. Changez un commentaire dans votre README ? Réinstallation complète de toutes les dépendances. Les builds qui prennent 30 secondes devraient en prendre 5.

Pas de health checks#

Sans health check, Docker (et Kubernetes) ne savent pas si votre application est réellement en bonne santé. Le processus pourrait tourner mais être deadlocked, à court de mémoire, ou incapable de se connecter à la base de données. L'orchestrateur pense que tout va bien parce que le processus existe.

Secrets dans les variables d'environnement#

Passer des secrets via ENV ou -e dans docker run les incorpore dans les couches de l'image ou les rend visibles dans docker inspect. Ce ne sont pas des endroits sûrs.

La configuration multi-étapes#

Voici le Dockerfile que j'utilise réellement. Je vais l'expliquer étape par étape :

dockerfile
# Étape 1 : Image de base avec configuration commune
FROM node:20-slim AS base
 
# Définir le répertoire de travail
WORKDIR /app
 
# Installer dumb-init pour une gestion correcte des signaux
RUN apt-get update && apt-get install -y --no-install-recommends dumb-init \
    && rm -rf /var/lib/apt/lists/*
 
# Étape 2 : Installer les dépendances
FROM base AS deps
 
# Copier les fichiers de dépendances en premier (cache de couche)
COPY package.json package-lock.json ./
 
# Installer TOUTES les dépendances (y compris dev pour le build)
RUN npm ci --ignore-scripts
 
# Étape 3 : Builder
FROM deps AS builder
 
# Copier le code source
COPY . .
 
# Générer le build
RUN npm run build
 
# Supprimer les devDependencies après le build
RUN npm prune --production
 
# Étape 4 : Image de production
FROM base AS runner
 
# Créer un utilisateur non-root
RUN groupadd --system --gid 1001 nodejs \
    && useradd --system --uid 1001 --gid nodejs nodejs
 
# Copier uniquement ce qui est nécessaire depuis le builder
COPY --from=builder --chown=nodejs:nodejs /app/dist ./dist
COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nodejs:nodejs /app/package.json ./package.json
 
# Passer à l'utilisateur non-root
USER nodejs
 
# Exposer le port
EXPOSE 3000
 
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
    CMD node -e "fetch('http://localhost:3000/health').then(r => { if (!r.ok) throw new Error(); })"
 
# Utiliser dumb-init comme PID 1
ENTRYPOINT ["dumb-init", "--"]
 
# Démarrer l'application
CMD ["node", "dist/server.js"]

Décortiquons pourquoi chaque décision compte.

Pourquoi node:20-slim et non node:20 ou node:20-alpine#

L'image node:20 est basée sur Debian et inclut des outils de build, Python, make, gcc — tout ce qu'il faut pour compiler des modules natifs. En production, vous n'avez besoin d'aucun de cela. C'est 350 Mo de surface d'attaque.

node:20-slim est une version allégée de Debian. Elle a ce dont Node.js a besoin pour fonctionner et rien de plus. Environ 80 Mo au lieu de 350 Mo.

Et Alpine ? node:20-alpine est encore plus petite (~50 Mo), et beaucoup de gens la recommandent. Je l'ai utilisée pendant des années. Puis j'ai rencontré des problèmes :

dockerfile
# Alpine utilise musl libc au lieu de glibc
# Cela entraîne des différences subtiles de comportement :
 
# 1. La résolution DNS fonctionne différemment
# Alpine n'utilise pas nsswitch.conf, ce qui peut causer des problèmes
# avec le DNS Kubernetes ou Docker Compose.
 
# 2. Les modules natifs nécessitent une compilation différente
# bcrypt, sharp, canvas — tous doivent être compilés pour musl
# Parfois cela fonctionne. Parfois vous obtenez des erreurs de segfault.
 
# 3. Les performances peuvent être moins bonnes
# musl alloue la mémoire différemment que glibc.
# Pour les charges de travail Node.js, c'est rarement significatif,
# mais le profilage peut montrer des différences inattendues.

Le delta de taille entre slim (~80 Mo) et alpine (~50 Mo) est de 30 Mo. Ça ne vaut pas les risques de compatibilité pour une application en production. Si vous êtes sûr à 100 % que vous n'avez pas de modules natifs et que vous ne ferez pas de résolution DNS complexe, Alpine convient. Sinon, utilisez slim.

Le problème du PID 1#

C'est un bug subtil que la plupart des gens ne découvrent jamais — jusqu'à ce que leurs conteneurs refusent de s'arrêter proprement.

Quand vous lancez CMD ["node", "server.js"], Node.js devient le PID 1 dans le conteneur. Le PID 1 a un rôle spécial sous Linux : il est censé être un processus init qui gère les signaux et collecte les processus zombies. Node.js n'est pas conçu pour ça.

Concrètement, ce qui se passe :

bash
# Vous envoyez SIGTERM au conteneur (c'est ce que fait docker stop)
docker stop mon-conteneur
 
# Docker envoie SIGTERM au PID 1 (votre processus Node.js)
# Mais le PID 1 reçoit un traitement spécial des signaux par le noyau :
# - Les processus normaux ont des gestionnaires de signaux par défaut
# - Le PID 1 N'A PAS de gestionnaires de signaux par défaut
# - Sauf si votre code gère explicitement SIGTERM, il est ignoré
 
# Docker attend 10 secondes (le délai par défaut)
# Rien ne se passe parce que Node.js a ignoré le signal
 
# Docker envoie SIGKILL (arrêt forcé non interceptable)
# Votre application s'arrête immédiatement sans nettoyage
# - Pas de fermeture propre des connexions à la base de données
# - Pas de fin des requêtes en cours
# - Pas de vidage des tampons d'écriture

dumb-init résout ce problème en étant le PID 1 et en transférant correctement les signaux à votre processus Node.js :

dockerfile
# dumb-init gère le rôle PID 1 correctement
ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "dist/server.js"]

Vous pouvez aussi gérer les signaux directement dans votre code Node.js :

javascript
// Gestion propre de l'arrêt
process.on("SIGTERM", async () => {
  console.log("SIGTERM reçu. Arrêt propre...");
 
  // Arrêter d'accepter de nouvelles connexions
  server.close();
 
  // Attendre la fin des requêtes en cours (avec timeout)
  await Promise.race([
    waitForConnections(),
    new Promise((resolve) => setTimeout(resolve, 10000)),
  ]);
 
  // Fermer les connexions à la base de données
  await db.end();
 
  process.exit(0);
});

En pratique, je fais les deux. dumb-init comme filet de sécurité, plus une gestion explicite des signaux dans l'application.

Le cache de couche qui compte vraiment#

Le cache de couche Docker est la fonctionnalité de performance la plus importante que la plupart des gens utilisent mal :

dockerfile
# ❌ Mauvais : chaque changement de fichier invalide le cache de npm install
COPY . .
RUN npm install
 
# ✅ Bon : les fichiers de dépendances changent rarement
COPY package.json package-lock.json ./
RUN npm ci
COPY . .

Le principe : Docker met en cache les couches dans l'ordre. Si la couche N change, toutes les couches après N sont aussi invalidées. En copiant les fichiers de dépendances d'abord et en installant, cette couche n'est invalidée que quand les dépendances changent — pas quand votre code applicatif change.

L'impact est significatif :

bash
# Sans cache de couche approprié :
# Chaque build : npm ci → 30-60 secondes
 
# Avec cache de couche approprié :
# Build quand les dépendances changent : 30-60 secondes
# Build quand seul le code change : 2-5 secondes (cache npm utilisé)

Il y a aussi un piège subtil avec .dockerignore. Sans lui, COPY . . inclut votre node_modules local, le répertoire .git, les fichiers .env et tout le reste. Créez toujours un .dockerignore :

dockerignore
node_modules
.git
.env
.env.*
*.md
.vscode
.idea
coverage
.nyc_output
dist

Le node_modules est particulièrement important. Sans l'ignorer, vous copiez vos modules locaux (qui peuvent être compilés pour votre OS) et les écrasez ensuite avec un npm install propre. Des octets gaspillés, du temps gaspillé, et potentiellement des bugs de compatibilité de plate-forme.

npm ci vs npm install#

Utilisez toujours npm ci dans les Dockerfiles. Toujours.

bash
# npm install :
# - Lit package.json
# - Résout les dépendances (peut obtenir des versions différentes selon le moment)
# - Met à jour package-lock.json s'il y a des écarts
# - Installe les packages
 
# npm ci :
# - Lit package-lock.json (et UNIQUEMENT package-lock.json)
# - Installe exactement les versions spécifiées
# - Supprime node_modules existant d'abord
# - Échoue si package-lock.json est désynchronisé avec package.json
# - Plus rapide car pas de résolution de dépendances

npm ci est déterministe. Les mêmes entrées produisent les mêmes résultats. C'est exactement ce que vous voulez dans un Dockerfile — des builds reproductibles.

Le flag --ignore-scripts est un renforcement de sécurité. Les scripts de packages (postinstall, etc.) peuvent exécuter du code arbitraire. Dans un build Docker, vous ne voulez probablement pas ça :

dockerfile
# Ignorer les scripts de packages pendant l'installation
RUN npm ci --ignore-scripts
 
# Si certains packages ont réellement besoin de scripts d'installation
# (par exemple, bcrypt qui doit être compilé), exécutez-les sélectivement :
RUN npm ci --ignore-scripts \
    && npm rebuild bcrypt

Configuration utilisateur non-root#

Créer et utiliser un utilisateur non-root :

dockerfile
# Créer un groupe et un utilisateur système
RUN groupadd --system --gid 1001 nodejs \
    && useradd --system --uid 1001 --gid nodejs nodejs
 
# Changer la propriété des fichiers de l'application
COPY --from=builder --chown=nodejs:nodejs /app/dist ./dist
 
# Passer à l'utilisateur non-root
USER nodejs

Pourquoi le GID/UID 1001 ? La convention. L'UID 1000 est souvent le premier utilisateur ordinaire sur un système Linux. L'UID 1001 évite les conflits potentiels et laisse entendre qu'il s'agit d'un UID lié à l'application.

Le flag --system crée un utilisateur sans répertoire home et sans shell de connexion. Exactement ce que vous voulez pour un processus de service.

En pratique, ça signifie :

bash
# Avec l'utilisateur par défaut (root) :
# - Le processus peut lire/écrire n'importe quel fichier du conteneur
# - Si un attaquant exploite une vulnérabilité, il a un accès root
# - Les montages de volumes ont des permissions root
 
# Avec l'utilisateur nodejs :
# - Le processus ne peut lire/écrire que les fichiers appartenant à nodejs
# - L'impact d'une vulnérabilité est limité
# - Les montages de volumes doivent avoir les permissions appropriées
 
# Vérifier que ça fonctionne :
docker exec mon-conteneur whoami
# nodejs
 
docker exec mon-conteneur id
# uid=1001(nodejs) gid=1001(nodejs) groups=1001(nodejs)

Un piège courant : les fichiers temporaires. Si votre application écrit dans /tmp ou dans d'autres répertoires, l'utilisateur non-root a besoin de permissions. Gérez ça dans le Dockerfile :

dockerfile
# Créer le répertoire temporaire avec les bonnes permissions
RUN mkdir -p /app/tmp && chown nodejs:nodejs /app/tmp
 
# Ou si vous avez besoin du répertoire de logs
RUN mkdir -p /app/logs && chown nodejs:nodejs /app/logs

Health checks qui fonctionnent réellement#

Les health checks Docker indiquent à l'orchestrateur si votre application est réellement en bonne santé, pas simplement si le processus tourne :

dockerfile
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
    CMD node -e "fetch('http://localhost:3000/health').then(r => { if (!r.ok) throw new Error(); })"

Détaillons les paramètres :

  • --interval=30s : vérification toutes les 30 secondes
  • --timeout=10s : le health check échoue s'il n'obtient pas de réponse dans les 10 secondes
  • --start-period=5s : attendre 5 secondes avant le premier check (temps de démarrage de l'application)
  • --retries=3 : marquer comme unhealthy après 3 échecs consécutifs

Le endpoint de health check dans votre application devrait vérifier les dépendances réelles :

javascript
app.get("/health", async (req, res) => {
  const checks = {
    uptime: process.uptime(),
    timestamp: Date.now(),
    status: "ok",
  };
 
  try {
    // Vérifier la connexion à la base de données
    await db.query("SELECT 1");
    checks.database = "connected";
  } catch (error) {
    checks.database = "disconnected";
    checks.status = "degraded";
  }
 
  try {
    // Vérifier la connexion Redis
    await redis.ping();
    checks.redis = "connected";
  } catch (error) {
    checks.redis = "disconnected";
    checks.status = "degraded";
  }
 
  const statusCode = checks.status === "ok" ? 200 : 503;
  res.status(statusCode).json(checks);
});

Un détail important : n'utilisez pas curl dans votre commande de health check. Cela nécessiterait d'installer curl dans votre image production — des octets supplémentaires et de la surface d'attaque. La commande node -e avec fetch() (disponible nativement depuis Node.js 18) fonctionne sans dépendance supplémentaire.

Si vous utilisez Kubernetes, sachez que les health checks Docker et les probes Kubernetes sont des choses différentes. Kubernetes ignore le HEALTHCHECK de Docker. Vous aurez besoin de configurer livenessProbe, readinessProbe et éventuellement startupProbe dans votre manifeste Kubernetes :

yaml
# kubernetes-deployment.yaml
spec:
  containers:
    - name: app
      livenessProbe:
        httpGet:
          path: /health
          port: 3000
        initialDelaySeconds: 10
        periodSeconds: 30
      readinessProbe:
        httpGet:
          path: /health
          port: 3000
        initialDelaySeconds: 5
        periodSeconds: 10

Gestion des secrets#

Les secrets dans les Dockerfiles sont un champ de mines. Voici ce qu'il ne faut PAS faire :

dockerfile
# ❌ Les secrets dans ENV sont visibles dans docker inspect
ENV DATABASE_URL=postgres://user:password@host:5432/db
 
# ❌ Les secrets dans les arguments de build sont dans l'historique de l'image
ARG DATABASE_PASSWORD
RUN echo $DATABASE_PASSWORD > /app/.env
 
# ❌ Copier des fichiers .env les incorpore dans les couches de l'image
COPY .env .env

Tous ces éléments stockent les secrets dans l'image elle-même. N'importe qui ayant accès à l'image peut les extraire.

Les secrets au runtime (la bonne approche)#

dockerfile
# Le Dockerfile ne contient aucun secret
# Les secrets sont passés au moment de l'exécution :
 
# Via les secrets Docker Swarm
docker service create \
    --secret db_password \
    --secret api_key \
    mon-app
 
# Via les secrets Kubernetes
kubectl create secret generic app-secrets \
    --from-literal=db-password=motdepasse \
    --from-literal=api-key=clé
 
# Via les fichiers d'environnement Docker Compose (non commités)
docker compose up  # avec env_file dans docker-compose.yml

Les secrets au moment du build (quand il le faut)#

Parfois vous avez besoin de secrets pendant le build — un jeton de registre npm privé, une clé SSH pour les repos privés. Docker BuildKit résout ça :

dockerfile
# syntax=docker/dockerfile:1
 
FROM node:20-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
 
# Monter le secret au moment du build — il n'est jamais stocké dans les couches de l'image
RUN --mount=type=secret,id=npm_token \
    NPM_TOKEN=$(cat /run/secrets/npm_token) \
    npm ci --ignore-scripts
 
# Construire avec :
# DOCKER_BUILDKIT=1 docker build --secret id=npm_token,src=.npmrc .

La partie --mount=type=secret est essentielle. Le secret est disponible pendant cette instruction RUN mais n'est pas incorporé dans la couche résultante. Il ne peut pas être extrait de l'image.

Optimisation de la taille de l'image#

La taille de l'image est importante pour des raisons pratiques — déploiements plus rapides, consommation de stockage réduite, surface d'attaque plus petite. Voici comment je l'optimise :

Le build multi-étapes est le plus gros gain#

dockerfile
# Taille sans multi-stage : ~600 Mo
# Taille avec multi-stage : ~150 Mo
 
# La différence vient de la non-inclusion de :
# - devDependencies (~200 Mo typiquement)
# - Code source (seul le code compilé est copié)
# - Outils de build, TypeScript, etc.
# - Fichiers de test, documentation

Prune de production#

dockerfile
# Après le build, supprimer les devDependencies
RUN npm prune --production
 
# Cela supprime des éléments comme :
# - typescript
# - eslint et plugins
# - vitest/jest
# - prettier
# - @types/*
# - Le code de tout outil de build

L'instruction .dockerignore#

Un .dockerignore approprié empêche les fichiers inutiles d'entrer dans le contexte de build :

dockerignore
# Dépendances
node_modules
 
# Contrôle de version
.git
.gitignore
 
# IDE
.vscode
.idea
*.swp
*.swo
 
# Tests et couverture
coverage
.nyc_output
*.test.ts
*.spec.ts
__tests__
 
# Documentation
*.md
LICENSE
 
# Environnement
.env
.env.*
 
# OS
.DS_Store
Thumbs.db
 
# Sortie de build (on reconstruit dans Docker)
dist
build
.next

Vérification de la taille de l'image#

Vérifiez toujours la taille finale de votre image :

bash
# Voir les tailles des images
docker images mon-app
# REPOSITORY   TAG       IMAGE ID       CREATED         SIZE
# mon-app      latest    abc123def456   2 minutes ago   147MB
 
# Plonger dans ce qui prend de la place
docker history mon-app:latest
 
# Pour une analyse détaillée, utilisez dive
# https://github.com/wagoodman/dive
dive mon-app:latest

dive est un outil remarquable. Il montre le contenu de chaque couche, met en évidence le gaspillage d'espace et évalue l'efficacité de l'image. Je le lance sur chaque Dockerfile de production.

Docker Compose pour le développement#

En production, votre Dockerfile est lean et optimisé. En développement, vous voulez de l'ergonomie — rechargement à chaud, outils de débogage, services locaux. Docker Compose relie les deux :

yaml
# docker-compose.yml
services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
      target: deps  # S'arrêter à l'étape des dépendances, pas au runner de production
    volumes:
      - .:/app              # Monter le code source pour le rechargement à chaud
      - /app/node_modules   # Mais NE PAS monter node_modules (utiliser ceux du conteneur)
    ports:
      - "3000:3000"
      - "9229:9229"         # Port de débogage Node.js
    environment:
      - NODE_ENV=development
      - DATABASE_URL=postgres://postgres:postgres@db:5432/myapp
      - REDIS_URL=redis://redis:6379
    command: npm run dev
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started
 
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: myapp
    volumes:
      - pgdata:/var/lib/postgresql/data
    ports:
      - "5432:5432"
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 5s
      retries: 5
 
  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    volumes:
      - redisdata:/data
 
volumes:
  pgdata:
  redisdata:

Le volume - /app/node_modules est un pattern important. Sans lui, votre montage local écraserait les node_modules dans le conteneur avec vos node_modules locaux (qui sont compilés pour votre OS hôte, pas pour Linux).

Override pour le développement#

Utilisez docker-compose.override.yml pour les paramètres spécifiques au développement :

yaml
# docker-compose.override.yml (chargé automatiquement)
services:
  app:
    environment:
      - DEBUG=app:*
      - LOG_LEVEL=debug
    # Activer le débogage Node.js
    command: node --inspect=0.0.0.0:9229 --watch src/server.ts

Ce fichier est automatiquement fusionné avec docker-compose.yml quand vous lancez docker compose up. Pas besoin de le spécifier explicitement.

Sécurité au-delà des bases#

Scan de l'image pour les vulnérabilités#

bash
# Le scan intégré de Docker
docker scout cves mon-app:latest
 
# Ou utilisez Trivy (open source, très complet)
trivy image mon-app:latest
 
# Dans la CI, faites échouer le build sur les vulnérabilités critiques
trivy image --exit-code 1 --severity CRITICAL mon-app:latest

Lancez ces scans dans la CI. Chaque build. Non négociable. Les vulnérabilités des images de base apparaissent régulièrement, et la seule façon de les détecter est de scanner.

Filesystem en lecture seule#

Si votre application n'a pas besoin d'écrire sur le filesystem (et la plupart des applications Node.js ne le devraient pas) :

bash
docker run --read-only \
    --tmpfs /tmp \
    mon-app:latest

Cela empêche un attaquant d'écrire des fichiers dans votre conteneur. Le montage tmpfs fournit un répertoire /tmp en écriture en mémoire pour les fichiers temporaires dont Node.js pourrait avoir besoin.

En Kubernetes :

yaml
securityContext:
  readOnlyRootFilesystem: true
  runAsNonRoot: true
  runAsUser: 1001
  allowPrivilegeEscalation: false
  capabilities:
    drop:
      - ALL

Limitation des capacités#

Les conteneurs Docker démarrent avec un ensemble de capacités Linux. La plupart sont inutiles pour les applications Node.js :

bash
docker run --cap-drop=ALL \
    --cap-add=NET_BIND_SERVICE \
    mon-app:latest

Cela supprime toutes les capacités et ne rajoute que celle pour se lier à des ports privilégiés (en dessous de 1024). Comme la plupart des applications Node.js tournent sur le port 3000+, vous pouvez même supprimer NET_BIND_SERVICE.

La configuration CI/CD#

Voici un pipeline GitHub Actions complet qui build, teste, scanne et pousse votre image Docker :

yaml
# .github/workflows/docker.yml
name: Docker Build and Push
 
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
 
jobs:
  build:
    runs-on: ubuntu-latest
 
    steps:
      - uses: actions/checkout@v4
 
      - name: Configurer Docker Buildx
        uses: docker/setup-buildx-action@v3
 
      - name: Se connecter au registre
        if: github.event_name == 'push'
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
 
      - name: Extraire les métadonnées
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ghcr.io/${{ github.repository }}
          tags: |
            type=sha,prefix=
            type=ref,event=branch
            type=semver,pattern={{version}}
 
      - name: Build et push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: ${{ github.event_name == 'push' }}
          tags: ${{ steps.meta.outputs.tags }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
 
      - name: Scanner les vulnérabilités
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: ghcr.io/${{ github.repository }}:${{ github.sha }}
          format: "sarif"
          output: "trivy-results.sarif"
          severity: "CRITICAL,HIGH"
 
      - name: Uploader les résultats du scan
        uses: github/codeql-action/upload-sarif@v3
        if: always()
        with:
          sarif_file: "trivy-results.sarif"

Les lignes cache-from et cache-to utilisent le cache GitHub Actions pour le cache de couches Docker. Cela accélère considérablement les builds CI — les dépendances inchangées sont mises en cache entre les exécutions.

Gestion de la mémoire dans les conteneurs#

Node.js et Docker ont un rapport compliqué avec la mémoire. Par défaut, Node.js fixe la taille de son tas en fonction de la mémoire système, pas de la limite de mémoire du conteneur. Cela signifie que si votre conteneur est limité à 512 Mo mais que le système a 16 Go, Node.js va utiliser beaucoup de mémoire et être tué par l'OOM killer.

dockerfile
# Définir la mémoire du tas Node.js explicitement
CMD ["node", "--max-old-space-size=384", "dist/server.js"]
 
# Ou utiliser la détection automatique (Node.js 20+)
CMD ["node", "--max-old-space-size=$(( $(cat /sys/fs/cgroup/memory.max) / 1024 / 1024 * 75 / 100 ))", "dist/server.js"]

La règle empirique : définir --max-old-space-size à environ 75 % de la limite de mémoire du conteneur. Les 25 % restants sont pour la pile, les tampons, les modules natifs et la mémoire off-heap de Node.js.

yaml
# docker-compose.yml
services:
  app:
    deploy:
      resources:
        limits:
          memory: 512M
        reservations:
          memory: 256M
    environment:
      - NODE_OPTIONS=--max-old-space-size=384

Le Dockerfile final de production#

Voici tout rassemblé, avec chaque optimisation dont nous avons discuté :

dockerfile
# syntax=docker/dockerfile:1
 
# --- Étape de base ---
FROM node:20-slim AS base
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends dumb-init \
    && rm -rf /var/lib/apt/lists/*
 
# --- Étape des dépendances ---
FROM base AS deps
COPY package.json package-lock.json ./
RUN npm ci --ignore-scripts
 
# --- Étape de build ---
FROM deps AS builder
COPY . .
RUN npm run build \
    && npm prune --production
 
# --- Étape de production ---
FROM base AS runner
 
# Métadonnées
LABEL org.opencontainers.image.source="https://github.com/votre-utilisateur/votre-repo"
LABEL org.opencontainers.image.description="Application Node.js de production"
 
# Utilisateur non-root
RUN groupadd --system --gid 1001 nodejs \
    && useradd --system --uid 1001 --gid nodejs nodejs
 
# Copier les artefacts de build
COPY --from=builder --chown=nodejs:nodejs /app/dist ./dist
COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nodejs:nodejs /app/package.json ./package.json
 
# Passer à l'utilisateur non-root
USER nodejs
 
# Définir l'environnement
ENV NODE_ENV=production
ENV PORT=3000
 
# Exposer le port
EXPOSE $PORT
 
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
    CMD node -e "fetch('http://localhost:3000/health').then(r => { if (!r.ok) throw new Error(); })"
 
# Utiliser dumb-init comme PID 1
ENTRYPOINT ["dumb-init", "--"]
 
# Démarrer avec les bonnes options mémoire
CMD ["node", "--max-old-space-size=384", "dist/server.js"]

Ce Dockerfile produit une image d'environ 150 Mo, tourne en tant qu'utilisateur non-root, gère les signaux correctement, dispose de health checks, ne contient aucun secret et se build rapidement grâce au cache de couches.

Ce n'est pas le Dockerfile le plus court que vous verrez. Mais c'est un Dockerfile que vous pouvez déployer en production et oublier, au lieu de le déployer et de vous réveiller à 3 heures du matin parce qu'un conteneur refuse de s'arrêter ou parce qu'un audit de sécurité vous a signalé en root.

La complexité supplémentaire se justifie. Chaque ligne existe pour une raison que j'ai apprise à la dure. Vous n'êtes pas obligé de les apprendre à la dure aussi.

Articles similaires