Sécuriser vos conteneurs Docker
La sécurité des conteneurs est un sujet critique. Voici les pratiques essentielles pour protéger vos applications conteneurisées.
Un conteneur n'est pas une machine virtuelle : tous les conteneurs d'un hôte partagent le même noyau Linux. L'isolation repose sur les namespaces, les cgroups, les capabilities et les profils seccomp. Elle est efficace, mais chaque réglage trop permissif (processus root, capabilities superflues, socket Docker monté, port ouvert) élargit ce qu'un attaquant peut faire après avoir compromis une application. L'objectif est donc de réduire, couche par couche, la surface d'attaque : l'image, l'utilisateur, les droits à l'exécution, le réseau et les secrets.
Principe du moindre privilège
# Ne JAMAIS exécuter en root
FROM php:8.3-fpm-alpine
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
WORKDIR /app
COPY --chown=appuser:appgroup . .
Par défaut, un processus dans un conteneur tourne en root. Si l'application est compromise (injection de commande, dépendance vulnérable), l'attaquant est root dans le conteneur, peut modifier le code, installer des outils, et se trouve en meilleure position pour tenter une évasion vers l'hôte. Avec USER, le processus principal tourne sous un utilisateur système sans privilèges, créé ici avec les options -S d'Alpine.
Deux détails pour PHP-FPM : le processus maître n'étant plus root, il ne peut pas changer d'utilisateur, et les directives user/group du pool sont ignorées (avec un simple avertissement dans les logs). Il doit aussi écouter sur un port supérieur à 1024, ce qui est le cas du port 9000 par défaut. Enfin, COPY --chown donne la propriété des fichiers à l'utilisateur applicatif ; si le code n'a pas besoin d'être modifiable, il est même préférable de le laisser appartenir à root et de n'ouvrir en écriture que les répertoires de cache et de logs.
Le moindre privilège s'applique aussi à l'exécution. Docker accorde par défaut un ensemble de capabilities Linux dont une application web n'a presque jamais besoin. Retirez-les toutes, interdisez l'élévation de privilèges et rendez le système de fichiers en lecture seule :
services:
app:
image: registry.example.com/myapp:1.4.2
user: "1000:1000"
read_only: true
tmpfs:
- /tmp
- /app/var
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
L'option no-new-privileges empêche un binaire setuid d'augmenter les droits du processus. Le mode read_only bloque l'écriture partout sauf dans les tmpfs déclarés : ici /tmp et le répertoire var/ de Symfony (cache et logs). Si votre application écrit ailleurs, elle échouera au démarrage, ce qui est un excellent moyen de découvrir ce qu'elle fait réellement. Ne lancez jamais un conteneur avec --privileged en production : cette option désactive l'essentiel de l'isolation.
Protéger le socket Docker
Monter /var/run/docker.sock dans un conteneur revient à lui donner le contrôle total de l'hôte : il peut lancer un conteneur privilégié qui monte le système de fichiers racine. Réservez-le aux rares outils qui en ont réellement besoin (reverse proxy, agent de monitoring), en lecture seule et idéalement derrière un proxy de socket qui filtre les appels autorisés. Pour la même raison, ajouter un utilisateur au groupe docker équivaut à lui donner les droits root. Le mode rootless de Docker, où le démon lui-même tourne sans privilèges, réduit fortement cet impact.
Scanner les vulnérabilités
Intégrez un scanner de vulnérabilités dans votre pipeline :
# Avec Trivy
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
aquasec/trivy image myapp:latest
# Avec Docker Scout
docker scout cves myapp:latest
Ces outils comparent les paquets du système et les dépendances applicatives (y compris le composer.lock) à des bases de CVE connues. Pour qu'un scan serve à quelque chose, il doit bloquer le pipeline quand il trouve une faille grave. Avec Trivy, le code de sortie est configurable :
trivy image --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 \
registry.example.com/myapp:1.4.2
--ignore-unfixed écarte les vulnérabilités pour lesquelles aucun correctif n'existe encore, afin de ne pas bloquer le pipeline sur des alertes impossibles à traiter. Scannez aussi périodiquement les images déjà en production : de nouvelles CVE sont publiées chaque jour pour des paquets qui étaient sains au moment du build. Et reconstruisez régulièrement vos images pour récupérer les correctifs de l'image de base.
Réseau et isolation
- Créez des réseaux dédiés pour isoler les services
- N'exposez jamais les ports des bases de données
- Utilisez des secrets Docker plutôt que des variables d'environnement pour les données sensibles
- Activez le mode read-only pour les conteneurs stateless
Un réseau déclaré avec internal: true n'a aucun accès vers l'extérieur : la base de données peut y dialoguer avec l'application, mais ne peut ni être jointe depuis Internet ni initier de connexion sortante. Seul le reverse proxy est publié sur l'hôte :
services:
proxy:
image: nginx:1.27-alpine
ports:
- "443:443"
networks: [frontend]
app:
image: registry.example.com/myapp:1.4.2
networks: [frontend, backend]
db:
image: mysql:8.4
networks: [backend]
networks:
frontend:
backend:
internal: true
Rappelez-vous que les ports publiés par Docker sont ouverts via des règles iptables qui passent souvent avant celles d'UFW : un port publié est accessible même si le pare-feu semble le bloquer. Si un service doit être joignable depuis l'hôte uniquement, publiez-le sur 127.0.0.1. Le fonctionnement des réseaux est détaillé dans le guide du réseau Docker.
Gestion des secrets
services:
app:
secrets:
- db_password
- api_key
secrets:
db_password:
file: ./secrets/db_password.txt
api_key:
external: true
Les variables d'environnement fuient facilement : elles apparaissent dans docker inspect, sont héritées par tous les processus enfants et finissent parfois dans des rapports d'erreur. Un secret est monté sous forme de fichier dans /run/secrets/<nom>, lisible uniquement dans les conteneurs qui le déclarent. Un secret file est lu depuis l'hôte (le fichier ne doit évidemment pas être commité) ; un secret external doit déjà exister dans le cluster (docker secret create), ce qui suppose Docker Swarm.
Beaucoup d'images officielles acceptent des variables suffixées par _FILE (par exemple MYSQL_PASSWORD_FILE). Côté Symfony, les processeurs de variables d'environnement file et trim lisent directement le fichier :
# config/packages/doctrine.yaml
parameters:
env(DB_PASSWORD_FILE): '/run/secrets/db_password'
doctrine:
dbal:
driver: pdo_mysql
host: db
dbname: app
user: app
password: '%env(trim:file:DB_PASSWORD_FILE)%'
Attention aussi aux secrets nécessaires pendant le build (jeton d'accès à un dépôt Composer privé, par exemple). Un ARG ou un ENV reste visible dans l'historique de l'image. Utilisez plutôt un montage de secret BuildKit, qui n'est jamais écrit dans une couche :
# syntax=docker/dockerfile:1
FROM composer:2 AS vendor
WORKDIR /app
COPY composer.json composer.lock ./
RUN --mount=type=secret,id=composer_auth,target=/app/auth.json \
composer install --no-dev --no-scripts --prefer-dist
# Build : docker build --secret id=composer_auth,src=$HOME/.composer/auth.json .
Images de confiance
Utilisez uniquement des images officielles ou vérifiées. Épinglez les versions exactes plutôt que d'utiliser latest. Signez vos images avec Docker Content Trust.
Un tag comme php:8.3-fpm-alpine est mobile : il pointe vers une nouvelle image à chaque mise à jour. Pour une reproductibilité totale, épinglez le digest (FROM php:8.3-fpm-alpine@sha256:…) et laissez un outil comme Renovate ou Dependabot proposer les mises à jour. Côté signature, Docker Content Trust repose sur Notary v1, que Docker est en train d'abandonner ; pour signer vos propres images, Sigstore Cosign est aujourd'hui l'option la plus répandue :
# Connaître le digest d'une image
docker buildx imagetools inspect php:8.3-fpm-alpine
# Signer puis vérifier une image avec Cosign
cosign sign --key cosign.key registry.example.com/myapp:1.4.2
cosign verify --key cosign.pub registry.example.com/myapp:1.4.2
Choisissez enfin des images de base minimales (Alpine, variantes slim ou images distroless) : chaque paquet absent est une vulnérabilité potentielle en moins.
Checklist
- Image de base minimale, versionnée, reconstruite régulièrement
- Processus non-root, capabilities retirées,
no-new-privileges - Système de fichiers en lecture seule pour les conteneurs sans état
- Scan de vulnérabilités bloquant dans la CI
- Réseaux séparés, bases de données non publiées
- Secrets en fichiers, jamais dans l'image ni dans le dépôt
- Socket Docker jamais monté dans un conteneur applicatif
Aucune de ces mesures n'est suffisante seule, mais leur combinaison transforme la compromission d'une application en incident contenu plutôt qu'en prise de contrôle du serveur.