Aller au contenu

Docker - Les bonnes pratiques

Cette fiche s'efforce de résumer un ensemble de bonnes pratiques classiques (c.f. références) complétées de recommandations visant entre autres à guider dans la production d'images pouvant être exécutées dans des environnements sécurisés (c.f. kubernetes.io - Pod Security Standards et kyverno/policies - baseline et restricted)

Les bonnes pratiques classiques

Empaqueter une seule application par conteneur

  • Appliquer tant que possible la règle un conteneur = un service.
  • Éviter par exemple de produire une image incluant une application et une instance PostgreSQL.
  • Utiliser plutôt un fichier docker-compose.yaml pour faciliter le démarrage de l'application :
services:
  app:
    image: myapp:latest
    ports:
      - "5000:5000"
  db:
    image: postgres:15
    volumes:
      - pg-data:/var/lib/postgresql/data

volumes:
  pg-data:

NB : Il est possible (et recommandé) d'inclure dans une même image un service et les utilitaires de ce service (ex : pg_dump et psql pour PostgreSQL)

Faire en sorte qu'un conteneur puisse être recréé sans perte de données

  • Préférer si possible l'utilisation de services dédiés au stockage (base de données, stockage objet,...) pour les données applicatives plutôt que l'utilisation de fichiers.
  • Identifier clairement les dossiers contenant des données persistantes dans le cas contraire (ex : /var/lib/postgresql/data pour PostgreSQL).
  • Utiliser des volumes pour la persistence des données :
services:
  db:
    image: mysql:5.7
    volumes:
      - db-data:/var/lib/mysql
volumes:
  db-data:

Ne pas inclure des secrets dans les images

  • Ne pas inclure les paramètres pour l'exécution dans l'image
  • Utiliser plutôt des variables d'environnement (ex : DATABASE_URL ou DB_PASSWORD, c.f. 12 facteurs - III. Configuration - Stockez la configuration dans l’environnement)
  • Ne pas inclure le dossier .git dans les images :
  • Inclure .git dans .dockerignore.
  • Éviter COPY . ..
  • Ne pas utiliser --build-arg pour fournir des secrets à la construction.
  • Savoir que docker image history ... permet de récupérer les secrets correspondants.
  • Éviter les dépendances privées (git, npm, PHP composer...) impliquant une authentification (donc un risque d'inclure un secret dans l'image)
  • Consulter docs.docker.com - Build secrets si vous ne pouvez vraiment pas éviter de telles dépendances...
  • Veiller à ne pas inclure un fichier .env incluant des secrets utilisés pour le développement dans l'image :
  • Option 1) Si le framework propose de gérer de tels fichiers à la racine du projet (ex : PHP Symfony), être très attentif à leurs présences dans .dockerignore et .gitignore.
  • Option 2) Pour limiter réellement le risque d'inclure de tels fichiers dans une image, stocker et chiffrer ces secrets loin du Dockerfile et des dépôts GIT (ex : une partition chiffrée).

Utiliser le fichier .dockerignore pour exclure les fichiers inutiles ou dangereux

  • Ajouter un fichier .dockerignore pour exclure les fichiers inutiles ou dangereux (.git) :
node_modules
.git
*.log
*.md
.dockerignore

Minimiser la taille des images

  • Installer uniquement ce qui est nécessaire.
  • Comprendre et exploiter les mécanismes de couches dans les images (docker image history mon-image)
  • Grouper les instructions pour supprimer les fichiers temporaires après installation des dépendances :
# Supprimer les fichiers temporaires après installation des dépendances
RUN apt-get update 
 && apt-get install -y wget gdal-bin \
 && rm -rf /var/lib/apt/lists/*
  • Ordonner les instructions pour profiter de la mise en cache :
# CONTRE-EXEMPLE

# en ajoutant le code avant l'installation de curl...
COPY . .

# ... curl est installé à chaque mise à jour du code
RUN apt-get update 
 && apt-get install -y curl \
 && rm -rf /var/lib/apt/lists/*
  • Utiliser des constructions multi-étapes (multi-stage builds) pour éviter de conserver les éléments relatifs à la construction :
# Etape 1 : Construire le site statique
FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build

# Etape 2 : Copier le résultat de la construction dans une image nginx pour l'exécution
FROM nginx:alpine
COPY --from=builder /app/build /usr/share/nginx/html

Minimiser le temps de construction des images

  • Comprendre et exploiter les mécanismes de cache dans la construction :
# Par exemple, pour ne pas exécuter npm install à chaque changement dans src/ :
# 1) installer les dépendances
COPY package.json package-lock.json .
RUN npm install 
# 2) ajouter les fichiers dynamiques dans un second temps
COPY src/ src

Les bonnes pratiques pour l'observabilité

Respecter le cadre pour les journaux applicatif

  • Écrire les journaux dans les flux standard (stdout et stderr) en utilisant des formats standards (nginx, apache2, json,...).
  • Rediriger les écritures dans des fichiers dans ces flux standard s'il n'est pas possible d'adapter le code en ce sens :
# Exemple avec nginx
RUN ln -sf /dev/stdout /var/log/nginx/access.log \
 && ln -sf /dev/stderr /var/log/nginx/error.log

Permettre la surveillance de l'état du service

  • Prévoir un mécanisme pour la surveillance de l'état du conteneur (ex : URL /health)
  • Envisager 1 la déclaration du HEALTHCHECK correspondant dans le conteneur :
# Ajouter un healthcheck
HEALTHCHECK CMD curl --fail http://localhost:8080/health || exit 1

Les bonnes pratiques pour la sécurité

Ne pas exécuter n'importe quoi ou n'importe quelle image

Cette précaution de base qui s'applique à d'autres outils susceptibles d'exécuter du code malveillant (npm, PHP composer,...) s'applique évidemment aussi pour docker :

  • Utiliser des images officielles ou des images d'éditeurs reconnus.
  • Installer des composants en provenance d'éditeurs reconnus dans vos images.

Ne pas exécuter les conteneurs en tant qu'utilisateur root

  • Utiliser un utilisateur dédié à l'application pour l'exécution :
# Exemple avec NodeJS où l'image inclue un utilisateur "node" (uid=1000)
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
USER node
CMD ["node", "app.js"]
  • Prévoir des volumes avec les permissions adaptées dans l'image :
# Exemple de dossier /app/data modifiable à l'exécution
ENV DATA_DIR=/app/data
RUN mkdir /app/data \
 && chown -R node:node/app/data
VOLUME /app/data

Utiliser des ports non privilégiés pour les services

Pourquoi?

L'utilisation du port 80 bloque l'activation des options de sécurité runAsNonRoot: true et runAsUser: 1000 en contexte Kubernetes sur de nombreuses images.

  • Pour vos application, utiliser des ports non privilégiés pour vos applications (>1024) (ex : APP_PORT=3000)
  • Pour les services tiers, utiliser des images traitant cette problématique (ex : nginx -> nginxinc/nginx-unprivileged)

Configurer par défaut pour la production

En pratique :

  • Configurer les variables d'environnement avec des valeurs par défaut adaptées pour la production au niveau de l'image :
# utiliser la version optimisée par défaut...
ENV APP_ENV=production
# ne pas activer des fonctionnalités de debug dangereuse par défaut
ENV APP_DEBUG=false
# ne pas produire inutilement trop de logs
ENV LOG_LEVEL=INFO
# ...
  • Utiliser au besoin des valeurs adaptées pour le DEV au niveau du fichier docker-compose.yaml correspondant :
services:
  app:
    build: .
    environment:
      APP_ENV: ${APP_ENV:-dev}
      APP_DEBUG: ${APP_DEBUG:-true}
      LOG_LEVEL: ${LOG_LEVEL:-DEBUG}

Utiliser des conteneurs avec un système de fichiers en lecture seule

Info

Avoir un conteneur avec un système de fichier en lecture seule (docker run --read-only, readOnlyRootFilesystem: true avec K8S) présente différents avantages : - Bloquer les modifications sur l'application en cas d'attaque. - Identifier les dossiers contenant des données dynamiques (et pouvoir se protéger contre un risque de full).

Pour permettre l'utilisation d'une image que l'on met à disposition avec un système de fichier en lecture seule :

  • Identifier les dossiers dynamiques dans l'image pour lesquels il conviendra de monter des volumes (ex : /app/data, /app/config,...)
  • Ne pas inclure du contenu statique dans ces dossiers dynamique (ex : un fichier /app/config/runtime.conf est généré au démarrage et /app/config/static.conf est inclu dans l'image [^2])

[^2] Avec Kubernetes et l'option readOnlyRootFilesystem: true, permettre la génération au démarrage d'un fichier /app/config/params.yaml sans vider /app/config sera délicat. Contrairement au cas docker où l'on utilisera un volume nommé, un volume emptyDir monté sur /app/config ne conservera pas le contenu original de l'image.

Scanner régulièrement les images pour détecter des failles ou des secrets

Plusieurs options sont possibles :

  • Utiliser les scanners de vulnérabilité au niveau des dépôts d'images (ex : DockerHub, GitHub Container Registry, Harbor,...).
  • Utiliser des outils tels Trivy (qui est utilisé par Harbor) ou Clair en local et au niveau CI/CD (GitHub actions, GitLab-CI,...).

Configurer les options de sécurité au niveau du démon

Plusieurs options de sécurité limitent les risques liés à l'utilisation de docker. Par exemple, l'option userns-remap permet d'associer l'utilisateur "root" au niveau des conteneurs à un utilisateur non root sur le hôte.

  • Configurer le démon docker en activant les options de sécurité.
  • Vérifier la configuration du démon docker à l'aide d'outils tel docker-bench-security.

Se méfier des expositions de port

Si vous utiliser docker sur une machine exposée, vous devrez éviter les expositions sur des ports (ex : -p 5432:5432). En effet, docker manipule iptables et les règles de pare-feu local que vous configurez par exemple avec UFW seront tout simplement ignorées..

  • Utiliser des réseaux pour la communication entre les conteneurs.
  • Accéder au besoin aux services non exposés depuis l'hôte :
  • Via les IP des conteneurs (docker inspect ...).
  • En mappant les ports uniquement sur l'hôte (ex : -p 127.0.0.1:9200:9200)
  • Utiliser un reverse proxy tel Traefik pour limiter le nombre de port à exposer et pour avoir de jolies URL (ex : https://opensearch.dev.localhost).

Utiliser un miroir pour l'accès aux images publiques

Utiliser un miroir pour l'accès aux images DockerHub est important pour éviter d'atteindre la limite de pull (c.f. Docker Hub rate limit ) :

  • Configurer si possible l'utilisation transparente d'un miroir au niveau du démon docker (ou containerd).
  • Modifier le nom des images pour l'exécution dans le cas où cette configuration n'est pas traitée (nginx:latest -> dockerhub-proxy.exemple.fr/library/nginx:latest).
  • Permettre l'utilisation du miroir pour la construction dans le fichier Dockerfile (--build-arg registry=dockerhub-proxy.exemple.fr) :
ARG registry=docker.io
FROM ${registry}/library/ubuntu:22.04

Les bonnes pratiques pour la robustesse

Limiter l'utilisation des ressources

Pour éviter de consommer toutes les ressources de l'hôte et préparer des déploiements en environnement de production (ex : Kubernetes) où la consommation RAM / CPU doit être maîtrisée et déclarée pour assurer la stabilité :

  • Configurer les limites de consommation CPU et RAM :
services:
  app:
    image: myapp:latest
    deploy:
      resources:
        limits:
          cpus: "0.5"
          memory: "512M"

Traiter proprement les signaux SIGTERM pour assurer des arrêts gracieux

Cas des services

En substance, la commande docker stop mon-conteneur -t 600 ne doit pas mettre 10 minutes. Si c'est le cas, l'application ne s'arrête pas proprement lorsqu'elle reçoit un signal d'arrêt (SIGTERM) et docker finit par procéder à un arrêt brutal. Il en sera de même en environnement Kubernetes où devoir attendre 60 secondes pour que le conteneur s'arrête sera problématique.

  • Arrêter proprement le service en cas de réception d'un message SIGTERM :
/*
 * Exemple avec Node.js et Express
 * @see https://expressjs.com/en/advanced/healthcheck-graceful-shutdown.html
 */
const express = require('express');
const app = express();
const server = app.listen(3000);

process.on('SIGTERM', () => {
  debug('SIGTERM signal received: closing HTTP server');
  server.close(() => {
    debug('HTTP server closed');
  });
});

Cas des traitements longs

Pour les traitements longs, traiter SIGTERM sera synonyme de se mettre en capacité de recommencer ou de poursuivre le traitement s'il doit être interrompu pour une raison ou une autre (ex : redémarrage d'une machine, maintenance sur un noeud Kubernetes, libération d'une instance spot,...).

  • Écouter et faire suivre au besoin les messages SIGTERM (1)
  • Utiliser une stratégie adaptée au contexte pour permettre la reprise du traitement :
  • Remettre en pile le message correspondant au traitement réalisé.
  • Remettre l'état d'un traitement PROCESSING à PENDING pour redémarrage.
  • ...

(1) Pour les scripts Bash, voir www.baeldung.com - Handling Signals in Bash Script (commande trap) et www.baeldung.com - The Uses of the Exec Command (approche exec)

Cas particulier des images apache2

Pour vous éviter de passer des heures à vous demander pourquoi les scripts bash et PHP intégrés dans une image ne reçoivent pas le signal SIGTERM...

  • Savoir que dans le cas d'images intégrant le server apache2 (ex : php:8.3-apache), le signal SIGTERM est parfois remplacé par SIGWINCH :
STOPSIGNAL SIGWINCH

Ne pas modifier le signal d'arrêt par défaut

  • Ne pas utiliser le signal par défaut pour empaqueter une application qui écoute l'événement SIGWINCH (changement de taille de fenêtre) plutôt que l'événement SIGTERM :
# mauvaise pratique!
STOPSIGNAL SIGWINCH
  • Adapter plutôt le signal à l'aide d'un script bash :
# Version revisitée de https://github.com/docker-library/php/blob/master/8.3/bullseye/apache/apache2-foreground

# Start apache forwarding SIGINT and SIGTERM to SIGWINCH
APACHE2_PID=""
function stop_apache()
{
    if [ ! -z "$APACHE2_PID" ];
    then
        kill -s WINCH $APACHE2_PID
    fi
}

trap stop_apache SIGINT SIGTERM SIGWINCH

apache2 -DFOREGROUND "$@" &
APACHE2_PID=$!
wait $APACHE2_PID

Les bonnes pratiques spécifiques à la contrainte d'utilisation d'un proxy sortant

Ne pas gérer un proxy sortant dans le Dockerfile

  • Ne pas inclure des éléments relatifs à l'utilisation d'un proxy sortant dans le fichier Dockerfile :
# MAUVAISE PRATIQUE :
ENV HTTP_PROXY=http://proxy.devinez.fr:3128
ENV HTTPS_PROXY=http://proxy.devinez.fr:3128

Références


  1. Cette pratique n'est pas forcément très répandue dans les images usuelles. Elle est à articuler avec l'utilisation des Liveness, Readiness et Startup Probes si vous utilisez Kubernetes.