Aller au contenu
Sécuriser et limiter ses conteneurs

Sécuriser et limiter ses conteneurs

200 · Pratiquer ⏱ 1 h 10 dockerdocker-composetrivylinux

À la fin, vous saurez

  • Fixer des limites de mémoire, de processeur et de processus à chaque conteneur
  • Retirer toutes les capabilities et n'ajouter que celles qui sont nécessaires
  • Expliquer et démontrer l'effet de no-new-privileges sur un binaire setuid
  • Comparer userns-remap et le mode rootless, et leurs limites
  • Analyser une image avec Trivy et interpréter le résultat
  • Produire un fichier Compose durci et le confronter aux recommandations de l'ANSSI

Prérequis

Testé avec compose 5.5.1 docker 29.8.1 trivy 0.74.0 ubuntu 24.04 , vérifié le 1 octobre 2026

Pourquoi

Tout au long de ce cours, chaque leçon a eu sa partie Sécurité. Celle-ci les rassemble et va plus loin, parce qu'un conteneur lancé avec les réglages par défaut est raisonnablement isolé, pas solidement. Par défaut, le processus tourne en root (l'UID 0 de l'hôte), garde 14 capabilities, peut consommer toute la mémoire et créer autant de processus qu'il veut, écrire partout dans son système de fichiers, et exécuter un binaire setuid pour élever ses privilèges.

Chacun de ces défauts n'est exploitable que si un attaquant a déjà pris pied dans le conteneur, par une faille de l'application ou d'une dépendance. Mais c'est précisément le scénario qu'il faut préparer : une application finira par avoir une faille, et la question est de savoir ce que l'attaquant pourra faire ensuite. Le durcissement réduit ce rayon d'explosion : il transforme une compromission de l'application en incident contenu, au lieu d'une compromission du serveur et de ses voisins.

Pour les organismes publics et les opérateurs soumis à NIS2, la question n'est d'ailleurs pas facultative : l'ANSSI a publié en 2020 des recommandations dédiées au déploiement de conteneurs Docker, que cette leçon met en regard de chaque mesure.

Les concepts

La défense en profondeur, appliquée à un conteneur

Un attaquant qui exécute du code dans votre conteneur rencontre successivement plusieurs barrières indépendantes, que nous avons démontées à la leçon 2 :

    flowchart TB
  A["Code malveillant dans l'application"] --> U["Utilisateur non-root<br/>(USER, --user)"]
  U --> N["no-new-privileges<br/>(pas d'élévation par setuid)"]
  N --> C["Capabilities retirées<br/>(--cap-drop ALL)"]
  C --> S["seccomp + AppArmor/SELinux<br/>(appels système, accès)"]
  S --> F["Racine en lecture seule<br/>(--read-only)"]
  F --> R["Limites de ressources<br/>(cgroups)"]
  R --> UN["User namespace<br/>(root du conteneur ≠ root de l'hôte)"]
  UN --> H["Noyau et hôte"]
  

Chaque barrière est imparfaite ; ensemble, elles obligent un attaquant à trouver plusieurs failles indépendantes. La plupart sont gratuites : une option de lancement, sans effet sur le fonctionnement d'une application bien conçue.

Ce que recommande l'ANSSI

La fiche technique ANSSI-FT-082 regroupe ses recommandations en quatre familles. Voici leur correspondance avec ce cours :

Recommandation de l'ANSSIMise en œuvreLeçon
Isoler les systèmes de fichiers sensibles de l'hôte, restreindre l'accès aux répertoires sensiblesPas de montage de /, /etc, du socket Docker ; montages en lecture seule9
Restreindre l'accès aux périphériques de l'hôteJamais --privileged ; --device au cas par cas2, 12
Interdire la connexion au réseau docker0, un réseau dédié par connexionRéseaux définis par l'utilisateur, internal10
Isoler l'interface réseau de l'hôteJamais --network host sans justification10
Namespaces PID, IPC, UTS dédiésDéfaut de Docker ; ne pas utiliser --pid host, --ipc host2
Namespace USER dédiéuserns-remap ou mode rootless12
Interdire ou limiter les capabilities--cap-drop ALL, ajouts justifiés12
Cgroups dédiés, limiter mémoire et processeur--memory, --cpus, --pids-limit2, 12
Racine en lecture seule, stockage dédié pour les données temporaires et persistantes--read-only, tmpfs, volumes9, 12
Exporter les journauxPilote de journalisation, collecte centralisée4, 6

Les deux façons de ne plus être root sur l'hôte

Faire tourner l'application sous un utilisateur non privilégié (USER dans l'image, leçon 8) protège contre ce que root dans le conteneur pourrait faire. Mais le démon et les conteneurs qui ont besoin de root restent root sur l'hôte. Deux mécanismes vont plus loin, en utilisant le namespace user que Docker n'active pas par défaut (leçon 2) :

  • userns-remap : le démon reste root, mais chaque conteneur tourne dans un namespace user où son UID 0 correspond, sur l'hôte, à un UID élevé et sans droits (100000 par exemple). Un réglage du démon, transparent pour les utilisateurs de Docker.
  • Le mode rootless : le démon lui-même tourne sous un utilisateur ordinaire, dans un namespace user. Plus aucun composant de Docker n'est root. L'accès au socket ne donne plus que les droits de cet utilisateur.

En pratique

Limiter les ressources

La leçon 2 a montré le mécanisme ; voici les options qu'on fixe systématiquement :

OptionEffetFichier du cgroup
--memory 256mPlafond de mémoire, OOM au-delàmemory.max
--memory-swap 256mPlafond mémoire + swap (égal à --memory : pas de swap)memory.swap.max
--cpus 1.0Temps processeur, en nombre de cœurscpu.max
--pids-limit 100Nombre maximal de processus et de threadspids.max

Sans elles, un seul conteneur défaillant (une fuite de mémoire, une boucle qui crée des processus) peut épuiser l'hôte et faire tomber tous ses voisins : c'est un déni de service qui ne demande aucune attaque. Comment choisir les valeurs ? En mesurant l'application en conditions réelles (docker stats, leçon 6), puis en ajoutant une marge. Une limite trop basse provoque des OOM au premier pic de charge ; une limite absente n'est pas une limite.

Retirer toutes les capabilities

Le principe du moindre privilège appliqué aux capabilities : tout retirer, puis ajouter le strict nécessaire. Essayons avec nginx :

$ docker run --rm --cap-drop ALL nginx:1.29
...
/docker-entrypoint.sh: Configuration complete; ready for start up
2026/10/01 10:30:57 [emerg] 1#1: chown("/var/cache/nginx/client_temp", 101) failed (1: Operation not permitted)
nginx: [emerg] chown("/var/cache/nginx/client_temp", 101) failed (1: Operation not permitted)

Le message est précis : nginx démarre en root, prépare ses répertoires en les donnant à son utilisateur (UID 101, d'où le besoin de CAP_CHOWN), puis lance ses workers sous cet utilisateur (CAP_SETUID, CAP_SETGID). Ajoutons exactement cela :

$ docker run -d --name ng2 --cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID nginx:1.29
$ docker ps -a --filter name=ng2 --format '{{.Status}}'
Up 2 seconds
$ docker exec ng2 grep CapEff /proc/1/status
CapEff:	00000000000000c1

Trois capabilities au lieu de quatorze (0xc1 = bits 0, 6 et 7 : CHOWN, SETGID, SETUID). Et pourtant nginx écoute sur le port 80, un port « privilégié » qui exige normalement CAP_NET_BIND_SERVICE. L'explication :

$ docker exec ng2 cat /proc/sys/net/ipv4/ip_unprivileged_port_start
0

Docker abaisse à 0, dans le namespace réseau de chaque conteneur, le seuil des ports privilégiés : n'importe quel processus du conteneur peut écouter sur n'importe quel port. Ce réglage ne concerne que le namespace du conteneur, pas l'hôte.

Mieux encore : utiliser une image conçue pour ne jamais être root. Les mainteneurs de nginx publient une variante non privilégiée, qui écoute sur le port 8080 :

$ docker run -d --name ngu --cap-drop ALL --read-only --tmpfs /tmp nginxinc/nginx-unprivileged:1.29
$ docker ps -a --filter name=ngu --format '{{.Status}}'
Up 3 seconds
$ docker exec ngu id
uid=101(nginx) gid=101(nginx) groups=101(nginx)

Aucune capability, racine en lecture seule, utilisateur non-root dès le départ.

no-new-privileges

Un processus non-root peut redevenir root en exécutant un binaire setuid root : c'est le mécanisme de su, passwd ou mount. Les images de distribution en contiennent plusieurs. Pour le démontrer proprement, construisons une image avec une copie setuid de la commande id :

FROM debian:13
RUN cp /usr/bin/id /usr/local/bin/id-suid && chmod u+s /usr/local/bin/id-suid
$ docker build -q -t demo-suid .
$ docker run --rm --user 1000:1000 demo-suid sh -c 'ls -l /usr/local/bin/id-suid; id-suid'
-rwsr-xr-x 1 root root 51656 Oct  1 10:31 /usr/local/bin/id-suid
uid=1000 gid=1000 euid=0(root) groups=1000

Le processus est lancé en UID 1000, mais le binaire setuid lui donne l'UID effectif 0 (euid=0(root)) : il agit désormais en root. Avec l'option no-new-privileges :

$ docker run --rm --user 1000:1000 --security-opt no-new-privileges demo-suid id-suid
uid=1000 gid=1000 groups=1000

Le bit setuid est ignoré. Le drapeau no_new_privs du noyau garantit qu'aucun execve() ne peut accorder plus de privilèges que le processus n'en a déjà ; il est hérité par tous les descendants et ne peut pas être retiré. C'est une option sans contrepartie pour une application, à activer partout.

Note

no-new-privileges bloque l'élévation par exécution d'un binaire setuid ou doté de capabilities de fichier. Il n'empêche pas un processus root de renoncer à ses privilèges avec setuid() : c'est pourquoi PostgreSQL, dont le script d'entrée démarre en root puis passe à l'utilisateur postgres avec gosu, fonctionne très bien avec cette option.

Le conteneur durci, assemblé

Appliquons tout à l'application Signalements :

$ docker run -d --name durci \
    --cap-drop ALL \
    --security-opt no-new-privileges \
    --read-only --tmpfs /tmp:size=16m \
    -p 127.0.0.1:8000:8000 \
    signalements:1.0
$ docker exec durci grep -E 'CapEff|NoNewPrivs|Seccomp:' /proc/1/status
CapEff:	0000000000000000
NoNewPrivs:	1
Seccomp:	2
$ curl -s localhost:8000/sante
{"etat":"ok"}

Aucune capability, pas d'élévation possible, seccomp actif, racine en lecture seule, utilisateur 10001 (défini dans l'image), et l'application fonctionne. C'est le résultat de choix faits dès le Dockerfile (leçon 8) : une application conçue pour tourner sans privilèges se durcit sans effort.

userns-remap : root dans le conteneur, personne sur l'hôte

Le mode userns-remap se configure dans daemon.json. Dans la VM de laboratoire de la leçon 4 :

{
  "userns-remap": "default"
}
labo# dockerd --validate --config-file /etc/docker/daemon.json
... level=info msg="User namespaces: ID ranges will be mapped to subuid/subgid ranges of: dockremap"
... level=warning msg="userns remapping enabled, disabling containerd snapshotter"
configuration OK
labo# systemctl restart docker
labo# grep dockremap /etc/subuid /etc/subgid
/etc/subuid:dockremap:100000:65536
/etc/subgid:dockremap:100000:65536

La valeur default demande à Docker de créer un utilisateur dockremap et de lui réserver une plage d'UID subordonnés : 65 536 identifiants à partir de 100000. Lançons un conteneur :

labo# docker run -d --name remap alpine:3.22 sleep 300
labo# docker exec remap id
uid=0(root) gid=0(root) groups=0(root),...
labo# ps -o user,uid,cmd -C sleep
USER       UID CMD
root         0 sleep infinity
100000   100000 sleep 300

Dans le conteneur, le processus est root. Sur l'hôte, c'est l'UID 100000, qui ne possède rien et n'a aucun droit (la première ligne, sleep infinity en UID 0, est un autre processus de la VM, sans rapport avec le conteneur). Une évasion du conteneur laisserait l'attaquant avec cet UID sans pouvoir.

Le message d'avertissement de la validation signale la contrepartie principale avec Docker 29 : userns-remap désactive le magasin d'images containerd (leçon 7). Le démon revient à l'ancien stockage (overlay2) ; les images déjà téléchargées dans le magasin containerd ne sont plus visibles et doivent être téléchargées à nouveau. Autres limites : les fichiers des volumes et des montages appartiennent sur l'hôte à des UID décalés (leçon 9), ce qui complique les montages de répertoires ; et les options qui partagent un namespace de l'hôte (--pid host, --network host) demandent de désactiver le remappage pour ce conteneur (--userns host).

Le mode rootless

Le mode rootless va plus loin : dockerd, containerd et les conteneurs tournent tous sous votre compte, dans un namespace user. Il s'installe avec l'outil dockerd-rootless-setuptool.sh fourni par le paquet docker-ce-rootless-extras (leçon 4), crée un service systemd utilisateur, et expose un socket dans /run/user/<uid>/docker.sock. Ses limites, documentées par Docker, sont à connaître avant de le choisir :

  • les limites de ressources (--memory, --cpus) exigent cgroups v2 et la délégation des contrôleurs par systemd à l'utilisateur ;
  • le réseau passe par une pile en espace utilisateur, plus lente que le pont du noyau, et l'adresse IP source des clients n'est pas toujours conservée ;
  • l'écoute sur les ports inférieurs à 1024 de l'hôte demande un réglage système (net.ipv4.ip_unprivileged_port_start) ;
  • sur Ubuntu 24.04, la restriction AppArmor des user namespaces non privilégiés (leçon 2) impose un profil pour rootlesskit, que la documentation fournit.

C'est l'option la plus sûre pour un poste de développement partagé ou une machine de CI, et une option sérieuse pour un serveur dont les applications s'accommodent de ces limites.

Analyser les vulnérabilités d'une image

Une image durcie à l'exécution peut contenir des paquets vulnérables. Trivy, un analyseur libre, compare les paquets d'une image à des bases de vulnérabilités. Pour ne pas lui donner accès au socket Docker (leçon 3), on lui fournit l'image sous forme d'archive :

$ docker save signalements:1.0 -o signalements.tar
$ docker run --rm -v "$PWD":/travail:ro -v trivy-cache:/root/.cache aquasec/trivy:0.74.0 \
    image --quiet --input /travail/signalements.tar --severity HIGH,CRITICAL
Report Summary

┌────────────────────────────────────────────────────────────────────────────────┬────────────┬─────────────────┬─────────┐
│                                     Target                                     │    Type    │ Vulnerabilities │ Secrets │
├────────────────────────────────────────────────────────────────────────────────┼────────────┼─────────────────┼─────────┤
│ /travail/signalements.tar (debian 13.7)                                        │   debian   │       51        │    -    │
├────────────────────────────────────────────────────────────────────────────────┼────────────┼─────────────────┼─────────┤
│ Python                                                                         │ python-pkg │        4        │    -    │
...
/travail/signalements.tar (debian 13.7)
=======================================
Total: 51 (HIGH: 51, CRITICAL: 0)

(Sortie abrégée ; le tableau détaille ensuite chaque vulnérabilité.) 51 vulnérabilités de sévérité haute dans les paquets Debian de l'image de base, 4 dans des paquets Python, aucune dans nos dépendances applicatives. Avant de paniquer, regroupons-les par paquet et par disponibilité d'un correctif, avec la sortie JSON de Trivy :

$ docker run --rm -v "$PWD":/travail:ro -v trivy-cache:/root/.cache aquasec/trivy:0.74.0 \
    image --quiet --input /travail/signalements.tar --severity HIGH,CRITICAL --format json \
  | jq -r '.Results[] | select(.Vulnerabilities) | .Vulnerabilities[] | "\(.PkgName) (\(.FixedVersion // "aucun correctif"))"' \
  | sort | uniq -c | sort -rn | head -15
      4 util-linux (aucun correctif)
      4 mount (aucun correctif)
      4 login (aucun correctif)
      4 libuuid1 (aucun correctif)
      4 libsmartcols1 (aucun correctif)
      4 libmount1 (aucun correctif)
      4 liblastlog2-2 (aucun correctif)
      4 libblkid1 (aucun correctif)
      4 bsdutils (aucun correctif)
      2 urllib3 (2.8.0)
      2 openssl-provider-legacy (3.5.7-1~deb13u3)
      2 openssl (3.5.7-1~deb13u3)
      2 libssl3t64 (3.5.7-1~deb13u3)
      1 setuptools (78.1.1)
      1 perl-base (aucun correctif)

La lecture change tout :

  • La majorité n'a pas de correctif dans Debian : vulnérabilités jugées mineures par l'équipe de sécurité de la distribution, ou en attente. Elles concernent des outils (mount, login, util-linux) que notre application n'utilise jamais : leur présence dans l'image est le vrai problème, et une image plus minimale les ferait disparaître (cours suivant).
  • OpenSSL a un correctif. Il suffit de reconstruire l'image sur une version à jour de python:3.14-slim : docker build --pull force le téléchargement de la dernière image de base.
  • urllib3 et setuptools ne sont pas nos dépendances : ce sont des copies embarquées par pip lui-même (pip/_vendor/urllib3 dans l'image). pip ne sert qu'à la construction ; une image qui ne l'embarque pas à l'exécution (construction multi-étapes, cours suivant) les fait disparaître.

C'est l'essentiel du travail de gestion des vulnérabilités : trier, comprendre l'exposition réelle, reconstruire régulièrement, réduire la surface. Le cours Gestion des vulnérabilités y est consacré.

Le fichier Compose durci

Voici la version finale des services de Signalements, avec toutes les mesures de ce cours :

services:
  app:
    build: .
    image: signalements:1.0
    environment:
      DATABASE_URL: postgresql://signalements:${POSTGRES_PASSWORD:?à définir dans .env}@db:5432/signalements
      APP_VERSION: "1.0"
    ports:
      - "127.0.0.1:8000:8000"
    networks: [frontal, donnees]
    depends_on:
      db:
        condition: service_healthy
    read_only: true
    tmpfs:
      - /tmp:size=16m
    cap_drop: [ALL]
    security_opt:
      - no-new-privileges:true
    deploy:
      resources:
        limits:
          cpus: "1.0"
          memory: 256M
          pids: 100
    healthcheck:
      test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/sante', timeout=2)"]
      interval: 10s
      timeout: 3s
      retries: 3
      start_period: 10s
    restart: unless-stopped

  db:
    image: postgres:18.6
    environment:
      POSTGRES_USER: signalements
      POSTGRES_DB: signalements
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?à définir dans .env}
    volumes:
      - pgdata:/var/lib/postgresql
    shm_size: 128m
    security_opt:
      - no-new-privileges:true
    deploy:
      resources:
        limits:
          cpus: "2.0"
          memory: 1G
          pids: 200
    networks: [donnees]
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U signalements -d signalements -h 127.0.0.1"]
      interval: 5s
      timeout: 3s
      retries: 5
      start_period: 30s
    restart: unless-stopped

(Les sections networks et volumes sont inchangées.) La section deploy.resources.limits, longtemps réservée à Swarm, est appliquée par Compose sur un hôte seul. Vérifions ce que Docker a réellement appliqué :

$ docker compose up -d --wait
$ docker inspect signalements-app-1 --format 'app: Memory={{.HostConfig.Memory}} NanoCpus={{.HostConfig.NanoCpus}} PidsLimit={{.HostConfig.PidsLimit}} CapDrop={{.HostConfig.CapDrop}} SecurityOpt={{.HostConfig.SecurityOpt}} ReadOnly={{.HostConfig.ReadonlyRootfs}}'
app: Memory=268435456 NanoCpus=1000000000 PidsLimit=100 CapDrop=[ALL] SecurityOpt=[no-new-privileges:true] ReadOnly=true
$ docker inspect signalements-db-1 --format 'db: Memory={{.HostConfig.Memory}} ShmSize={{.HostConfig.ShmSize}}'
db: Memory=1073741824 ShmSize=134217728
$ docker stats --no-stream --format 'table {{.Name}}\t{{.MemUsage}}\t{{.PIDs}}' signalements-app-1 signalements-db-1
NAME                 MEM USAGE / LIMIT   PIDS
signalements-app-1   84.37MiB / 256MiB   3
signalements-db-1    38.42MiB / 1GiB     9
$ curl -s localhost:8000/sante
{"etat":"ok"}

Quelques choix méritent une explication :

  • La base garde ses capabilities par défaut : son script d'entrée en a besoin pour préparer le répertoire de données et changer d'utilisateur. On lui applique en revanche no-new-privileges, ses limites, et le réseau interne. Retirer des capabilities à une image tierce se fait au cas par cas, en lisant ses erreurs comme nous l'avons fait avec nginx.
  • shm_size : PostgreSQL utilise la mémoire partagée /dev/shm, limitée à 64 Mo par défaut dans un conteneur, ce qui provoque des erreurs sur des requêtes parallèles volumineuses.
  • La mémoire limite de l'application (256 Mo) a été choisie à partir de la mesure (84 Mo au repos, deux workers), avec une marge pour la charge.

Sous le capot

--privileged est l'inverse de tout ce qui précède : toutes les capabilities, seccomp et AppArmor désactivés, accès à tous les périphériques de l'hôte (/dev), /sys en écriture. Un processus root dans un conteneur privilégié peut monter le disque de l'hôte et en prendre le contrôle en une commande. Les rares usages légitimes (faire tourner Docker dans Docker pour une CI, certains outils système) doivent être isolés dans des machines dédiées.

Le profil seccomp par défaut de Docker est une liste d'autorisation : il autorise un peu plus de 400 noms d'appels système (toutes architectures confondues, certains seulement sous condition) et bloque les autres (chargement de modules, manipulation de l'horloge, kexec, certains appels liés aux namespaces...). Certains appels sont autorisés seulement si le conteneur a la capability correspondante. On peut fournir un profil plus strict avec --security-opt seccomp=profil.json ; ne le désactivez jamais (seccomp=unconfined) pour « faire marcher » une application sans comprendre quel appel elle demande.

Le user namespace maintient pour chaque conteneur une table de correspondance, visible dans /proc/<pid>/uid_map : « les UID 0 à 65535 du conteneur correspondent aux UID 100000 à 165535 de l'hôte ». Le noyau traduit chaque UID à la frontière, pour les vérifications de droits sur les fichiers comme pour l'affichage. Les capabilities d'un processus dans un user namespace ne valent que pour les ressources de ce namespace : root dans le conteneur peut changer le propriétaire d'un fichier du conteneur, pas d'un fichier de l'hôte.

Pièges courants

--privileged pour résoudre un Operation not permitted. C'est la correction la plus répandue et la plus dangereuse. Identifiez la barrière qui bloque (capability, seccomp, AppArmor, leçon 2) et levez seulement celle-là, pour cette opération.

Des limites mémoire sans mesure. Une limite trop basse produit des redémarrages par OOM (code 137, OOMKilled=true) au premier pic de charge. Mesurez en charge, puis fixez la limite avec une marge, et surveillez.

cap_drop: [ALL] sur une image qui démarre en root. Les images officielles de nginx, PostgreSQL, Redis démarrent en root pour préparer leurs répertoires. Soit vous ajoutez les capabilities qu'elles demandent, soit vous choisissez une variante non privilégiée, soit vous construisez votre propre image.

Activer userns-remap sur un hôte en service. Le démon change de stockage (le magasin containerd est désactivé) : images et conteneurs existants deviennent invisibles, et les volumes existants appartiennent aux mauvais UID. C'est un choix à faire à l'installation, pas en cours de route.

Croire qu'une image sans vulnérabilité est sûre. L'analyse ne voit que les paquets connus. Elle ne détecte ni un secret oublié (l'analyse par défaut de trivy image recherche aussi les secrets, d'où la colonne Secrets du rapport, mais aucun outil ne les trouve tous), ni une configuration dangereuse, ni une porte dérobée volontaire. C'est une mesure parmi d'autres.

Sécurité

Cette leçon est entièrement consacrée à la sécurité ; voici la liste de contrôle qui en résulte, à appliquer à chaque conteneur avant la production :

  1. Image d'origine connue, épinglée par empreinte, reconstruite régulièrement, analysée en CI (leçons 7 et 12).
  2. Utilisateur non-root dans l'image, UID numérique (leçon 8).
  3. --cap-drop ALL, ajouts justifiés et documentés.
  4. --security-opt no-new-privileges.
  5. Racine en lecture seule, tmpfs et volumes pour ce qui doit être écrit (leçon 9).
  6. Limites de mémoire, de processeur et de processus.
  7. Un réseau dédié, internal pour les services sans besoin de sortie, rien de publié sauf le point d'entrée, et sur une adresse précise (leçon 10).
  8. Jamais --privileged, jamais le socket Docker monté, jamais de namespace de l'hôte partagé sans justification écrite.
  9. Secrets hors de l'image et hors des variables d'environnement quand c'est possible (leçons 5 et 11).
  10. Journaux collectés hors de l'hôte (leçons 4 et 6).

En production

  • Automatisez la vérification. Le CIS Docker Benchmark et son outil docker-bench-security vérifient la configuration de l'hôte et des conteneurs ; Hadolint (Dockerfile) et KICS (Dockerfile et Compose) analysent les fichiers en CI. Une règle non vérifiée automatiquement finit par être oubliée.
  • Kubernetes reprend chaque mesure dans le securityContext des pods : runAsNonRoot, runAsUser, capabilities.drop: [ALL], allowPrivilegeEscalation: false (l'équivalent de no-new-privileges), readOnlyRootFilesystem, seccompProfile: RuntimeDefault, et resources.limits. Les Pod Security Standards (niveau restricted) les imposent à l'échelle d'un espace de noms. Les habitudes prises ici se transposent telles quelles.
  • Isolez selon la sensibilité. Pour des charges de sensibilités différentes ou du code non fiable, ajoutez une frontière plus forte que le noyau partagé : machines virtuelles dédiées, ou runtimes comme gVisor et Kata Containers (leçons 1 et 3).
  • Documentez les exceptions. Chaque capability ajoutée, chaque volume de l'hôte monté, chaque namespace partagé a une justification écrite à côté de la configuration. C'est ce que demandera un auditeur, et ce qui permettra de les retirer le jour où elles ne seront plus nécessaires.

Exercices

1. Le minimum pour Redis. Lancez redis:8 avec --cap-drop ALL. Fonctionne-t-il ? Sous quel UID tourne redis-server ? Trouvez la configuration qui le fait tourner sans root et sans aucune capability.

Solution

Surprise : avec --cap-drop ALL, Redis démarre, mais en UID 0 sans capabilities. Son script d'entrée vérifie s'il a le droit de changer d'utilisateur (setpriv -d) et, faute de capabilities, saute l'étape au lieu d'échouer :

$ docker run -d --name rd --cap-drop ALL redis:8
$ docker exec rd grep -E "CapEff|Uid" /proc/1/status
Uid:	0	0	0	0
CapEff:	0000000000000000

Ajouter seulement SETUID et SETGID le fait échouer (setpriv: apply bounding set: Operation not permitted) : l'outil setpriv veut aussi réduire l'ensemble limitant, ce qui demande SETPCAP. Avec les trois, Redis passe bien à l'UID 999 et termine sans capabilities. Mais la meilleure réponse est plus simple : démarrer directement sous l'utilisateur redis de l'image, ce qui rend toute capability inutile.

$ docker run --rm --entrypoint id redis:8 redis
uid=999(redis) gid=999(redis) groups=999(redis)
$ docker run -d --name rd --user 999:999 --cap-drop ALL redis:8
$ docker exec rd grep -E "CapEff|Uid" /proc/1/status
Uid:	999	999	999	999
CapEff:	0000000000000000

Leçon : lisez le script d'entrée d'une image avant de la durcir ; il fait souvent des choix silencieux selon les privilèges disponibles.

2. Mesurer avant de limiter. Lancez le projet Compose, puis générez de la charge sur l'application (par exemple 2 000 requêtes POST avec une boucle curl ou un outil comme hey). Relevez la mémoire maximale observée avec docker stats. La limite de 256 Mo est-elle adaptée ?

Solution

Lancez docker stats signalements-app-1 dans un terminal pendant la charge et notez le maximum de la colonne MEM USAGE. Sur la machine de test, 2 000 requêtes envoyées par 8 clients en parallèle ont fait culminer l'application à 101 Mio ; la limite de 256 Mio laisse une marge confortable. Si vous augmentez le nombre de workers, la mémoire croît à peu près linéairement : la limite doit suivre. Le bon réflexe est de consigner la mesure à côté de la limite dans le fichier Compose (en commentaire), pour que la valeur ne soit pas arbitraire.

3. Lire un rapport de vulnérabilités (niveau 200). Analysez l'image postgres:18.6 avec Trivy. Combien de vulnérabilités hautes et critiques ont un correctif disponible, et dans quels composants ? Que faites-vous des autres ?

Solution
$ docker save postgres:18.6 -o postgres.tar
$ docker run --rm -v "$PWD":/travail:ro -v trivy-cache:/root/.cache aquasec/trivy:0.74.0 \
    image --quiet --input /travail/postgres.tar --severity HIGH,CRITICAL --ignore-unfixed
...
/travail/postgres.tar (debian 13.7)
Total: 7 (HIGH: 7, CRITICAL: 0)
...
Total: 22 (HIGH: 21, CRITICAL: 1)

(Sortie abrégée, au 1er octobre 2026.) --ignore-unfixed ne garde que les vulnérabilités corrigeables : 29 au total. Les 7 premières concernent des paquets Debian (OpenSSL, PCRE2) déjà corrigés dans la distribution : une reconstruction de l'image officielle les fera disparaître. Les 22 autres, dont la seule critique, sont dans la bibliothèque standard de Go embarquée par gosu, le petit binaire qui sert au script d'entrée à abandonner les privilèges de root : il a été compilé avec Go 1.24.6. C'est un cas d'école : le composant est vulnérable « sur le papier », mais gosu n'utilise ni le réseau ni l'analyse de données externes concernées par la plupart de ces failles, et ses mainteneurs expliquent dans sa documentation pourquoi ces signalements sont en général sans effet. On évalue donc l'exposition réelle avant de s'alarmer, on documente la décision, et l'on suit la reconstruction de l'image. Pour une base de données, la priorité reste qu'elle ne soit joignable que par l'application, sur un réseau interne.

4. Audit d'un fichier Compose (niveau 300). Un prestataire livre ce service. Relevez chaque problème, classez-les par gravité et proposez une version corrigée.

services:
  supervision:
    image: outil-supervision:latest
    privileged: true
    network_mode: host
    pid: host
    volumes:
      - /:/hote
      - /var/run/docker.sock:/var/run/docker.sock
    environment:
      API_TOKEN: 3f9a7c2e1b...
Solution

Par gravité décroissante : privileged: true (aucune isolation), le socket Docker monté (contrôle total de l'hôte), / de l'hôte monté en écriture (accès à tous les fichiers), pid: host et network_mode: host (plus d'isolation des processus ni du réseau), le jeton d'API en clair dans le fichier (fuite par Git et par docker inspect), latest (image non reproductible). Un outil de supervision a souvent besoin de lire des métriques de l'hôte, pas de le contrôler : montez seulement ce qu'il lit, en lecture seule (/proc et /sys vers des chemins dédiés), retirez privileged, ajoutez cap_drop: [ALL] et les rares capabilities documentées par l'éditeur, passez le jeton par un secret, épinglez l'image. Si l'outil a vraiment besoin de l'API Docker, intercalez un proxy de socket en lecture seule qui ne laisse passer que les appels nécessaires. Exigez du prestataire la justification écrite de chaque privilège restant.

Récapitulatif

  • Un conteneur par défaut est raisonnablement isolé ; le durcissement réduit le rayon d'explosion d'une compromission de l'application.
  • Limites de mémoire, de processeur et de processus sur chaque conteneur, fondées sur des mesures.
  • --cap-drop ALL puis ajouts justifiés ; no-new-privileges bloque l'élévation par setuid ; racine en lecture seule avec tmpfs et volumes.
  • userns-remap fait du root du conteneur un UID sans droits sur l'hôte (au prix du magasin containerd avec Docker 29) ; le mode rootless retire root de toute la chaîne.
  • Analysez les images (Trivy), triez par correctif disponible et exposition réelle, reconstruisez régulièrement.
  • Jamais --privileged, jamais le socket Docker, jamais les namespaces de l'hôte sans justification écrite. La grille de l'ANSSI et le CIS Docker Benchmark servent de référence d'audit.

Pour aller plus loin

  • La fiche technique ANSSI-FT-082, Recommandations de sécurité relatives au déploiement de conteneurs Docker : une vingtaine de pages, en français, avec les commandes de vérification.
  • L'OWASP Docker Security Cheat Sheet, synthèse internationale des mêmes règles.
  • La documentation du drapeau no_new_privs du noyau Linux, courte et précise.
  • Pour la suite du parcours : Construire des images de conteneurs (images minimales, multi-étapes, signatures), puis Kubernetes : les fondamentaux, où chaque notion de ce cours prend sa forme à l'échelle d'un cluster.

Sources