Retour au cours

infra / docker-compose

Bonnes pratiques production (niveau expert)

Leçon 131 exercice

Explication

Ce que vous allez apprendre

  • Appliquer le principe du moindre privilège avec cap_drop: [ALL] et cap_add ciblé
  • Rendre un conteneur en lecture seule (read_only) sans casser son fonctionnement
  • Faire tourner un conteneur sous un utilisateur non-root avec user: "1000:1000"
  • Fixer des limites de ressources (deploy.resources.limits) pour protéger l'hôte partagé
  • Configurer une politique de redémarrage avec backoff pour éviter les boucles de crash

Dans quel contexte ?

Un audit de sécurité externe sur l'infrastructure de boutique-api signale que le conteneur api tourne en root, sans limite de mémoire, et peut écrire n'importe où sur son système de fichiers. Le rapport recommande d'appliquer les mêmes principes de durcissement qu'un serveur Linux classique : moindre privilège, utilisateur dédié, ressources bornées. Cette leçon applique ces principes directement dans le fichier Compose de production.

Un changement de niveau

D'abord, il faut réaliser que tout ce qui a été vu jusqu'ici permet de faire fonctionner une stack. Cette leçon aborde un niveau différent : la faire fonctionner de façon sûre, résiliente et respectueuse des ressources d'un serveur partagé par plusieurs projets.

Le principe du moindre privilège, étape par étape

D'abord, un conteneur ne devrait jamais avoir plus de droits que nécessaire. cap_drop: [ALL] retire toutes les capacités Linux avancées, comme le ferait un compte utilisateur sans droits admin.

Puis on redonne seulement le strict nécessaire

Une fois toutes les capacités retirées, cap_add ne réintroduit que celles réellement indispensables. Le conteneur part donc du minimum absolu, et on ajoute au cas par cas, jamais l'inverse.

Empêcher le conteneur d'écrire n'importe où

Ensuite, read_only: true empêche le conteneur d'écrire sur son propre système de fichiers, sauf les zones explicitement prévues comme /tmp via tmpfs. Cela limite fortement les dégâts si une vulnérabilité venait à être exploitée.

Ne jamais tourner en root

De la même façon, tourner sous un utilisateur non-root, avec user: "1000:1000", applique le même principe : même compromis, le processus ne peut pas endommager le système hôte.

Protéger l'hôte des débordements de ressources

Une fois la sécurité posée, il reste un autre risque : sans limites explicites via deploy.resources.limits, un service qui fuit de la mémoire ou boucle en CPU peut affamer TOUS les autres conteneurs du même serveur.

Ne pas oublier les logs

De même, sans rotation de logs configurée via logging.options.max-size, un service très bavard peut remplir le disque de l'hôte jusqu'à le rendre inutilisable — un incident classique en production.

Réglage de durcissementCe qu'il empêche
cap_drop: [ALL] + cap_add cibléUn conteneur compromis n'a que le strict nécessaire
read_only: trueÉcriture non prévue sur le système de fichiers du conteneur
user: "1000:1000"Exécution en root à l'intérieur du conteneur
no-new-privileges:trueEscalade de privilèges via un binaire setuid
deploy.resources.limitsUn service qui affame la mémoire/CPU de l'hôte partagé

Un dernier piège à surveiller

Attention cependant : une restart_policy sans backoff peut créer une boucle de redémarrage frénétique qui sature les ressources au lieu de laisser le temps de diagnostiquer le problème. Et multiplier les durcissements de sécurité sans jamais tester que le service fonctionne encore est risqué : read_only peut par exemple casser une application qui écrit là où on ne l'attendait pas.

Piège fréquent

Ajouter read_only: true sur un conteneur Node.js qui écrit des fichiers temporaires dans /app/tmp fait planter l'application avec une erreur "EROFS: read-only file system", sans lien apparent avec la sécurité. Ajoute systématiquement un tmpfs: [/tmp] (ou le dossier concerné) avant d'activer read_only, et teste le service après chaque durcissement.

Cette leçon marque une transition : la suivante quitte le sujet de la production pour accélérer, à l'inverse, la boucle de développement au quotidien, avec Compose Watch.

Commandes & code

Bonnes pratiques production

yaml
services:
  api:
    image: "myorg/api:${API_TAG}"
    restart: unless-stopped
    read_only: true                       # système de fichiers du conteneur en lecture seule
    tmpfs:
      - /tmp                              # dossier inscriptible temporaire hors image
    security_opt:
      - no-new-privileges:true            # empêche l'escalade de privilèges via setuid
    cap_drop:
      - ALL                               # on retire toutes les capabilities Linux...
    cap_add:
      - NET_BIND_SERVICE                  # ...et on ne rajoute que le strict nécessaire
    user: "1000:1000"                     # jamais root dans le conteneur
    deploy:
      resources:
        limits:
          cpus: "1.0"
          memory: 512M
        reservations:
          cpus: "0.25"
          memory: 128M
      restart_policy:
        condition: on-failure
        delay: 5s
        max_attempts: 5
        window: 120s
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:3000/health"]
      interval: 15s
      timeout: 5s
      retries: 3
      start_period: 20s
    logging:
      driver: json-file
      options:
        max-size: "20m"
        max-file: "5"
        tag: "{{.Name}}/{{.ID}}"
    ulimits:
      nofile:
        soft: 65536
        hard: 65536
yaml
# Reverse proxy avec TLS + rotation de logs devant l'API
services:
  proxy:
    image: nginx:1.27-alpine
    restart: unless-stopped
    ports:
      - "443:443"
    volumes:
      - ./nginx/prod.conf:/etc/nginx/nginx.conf:ro
      - tls_certs:/etc/nginx/certs:ro
    depends_on:
      api:
        condition: service_healthy
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

volumes:
  tls_certs:
    external: true
bash
# Déploiement "zero-downtime" simplifié via recréation contrôlée
docker compose pull                       # tire les nouvelles images
docker compose up -d --no-deps --wait api # attend que le healthcheck soit OK avant de continuer
docker image prune -f --filter "until=72h"

# Audit rapide de la configuration de sécurité effective
docker compose config | grep -E "read_only|cap_drop|user:|no-new-privileges"
yaml
# Limiter les logs bavards ET s'assurer qu'un service compromis ne peut pas
# épuiser le disque de l'hôte (attaque par déni de service locale)
services:
  batch_job:
    build: ./batch
    logging:
      driver: "local"          # driver "local" : rotation + compression natives, plus efficace
      options:
        max-size: "5m"
        max-file: "3"

Résumé

  • read_only, cap_drop: [ALL], no-new-privileges, user: non-root : durcissement systématique.
  • Toujours fixer resources.limits pour éviter qu'un service affame l'hôte.
  • restart_policy avec backoff (delay, max_attempts, window) évite les boucles de crash agressives.
  • --wait avec des healthchecks fiables permet un déploiement contrôlé sans downtime brutal.
  • Faire tourner les logs (max-size/max-file) pour ne jamais saturer le disque hôte.

Exercices pratiques

1 disponible
1

Mission : durcir l'API sans la casser

Objectif : Appliquer le moindre privilège sur un service de production sans provoquer de régression.

Contexte

Suite à l'audit de sécurité de boutique-api, quelqu'un ajoute read_only: true sur le service api. L'application Node.js plante immédiatement avec "EROFS: read-only file system" au démarrage, car elle écrit des fichiers temporaires dans /app/tmp. Il faut aussi retirer les capacités Linux inutiles et fixer des limites de ressources.

Durcis le service sans casser son fonctionnement.

Résoudre l’exercice →