Docker - Les bonnes pratiques¶
Cette fiche s'efforce de recenser des bonnes pratiques classiques (c.f. références) en les complétant pour guider dans la production d'images prêtes pour la production pouvant être exécutées dans des environnements sécurisés (c.f. kubernetes.io - Pod Security Standards et kyverno/policies - baseline et restricted)
- Généralités
- Empaqueter un seul service par conteneur
- Faire en sorte qu'un conteneur puisse être recréé sans perte de données
- Ne pas inclure des secrets dans les images
- Utiliser le fichier .dockerignore pour exclure les fichiers inutiles ou dangereux
- Minimiser la taille des images
- Minimiser le temps de construction des images
- Observabilité
- Sécurité
- Ne pas exécuter n'importe quoi ou n'importe quelle image
- Ne pas exécuter les conteneurs en tant qu'utilisateur root
- Utiliser des ports non privilégiés pour les services
- Configurer par défaut pour la production
- Utiliser des conteneurs avec un système de fichiers en lecture seule
- Scanner régulièrement les images
- Configurer les options de sécurité au niveau du démon
- Se méfier des expositions de port
- Robustesse
- Divers
- Références
Généralités¶
Empaqueter un seul service 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
Exemple APP + BDD
services:
app:
image: myapp:latest
ports:
- "5000:5000"
db:
image: postgres:15
volumes:
- pg-data:/var/lib/postgresql/data
volumes:
pg-data:
Cas des utilitaires
- Il est parfois commode d'inclure dans une même image un service et les utilitaires de ce service (ex : pg_dump et psql dans l'image officielle PostgreSQL).
- Il est toutefois possible de produire plusieurs versions de l'image (ex : une image alégée pour l'exécution du service et une image complète pour les jobs)
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/datapour PostgreSQL). - Utiliser des volumes pour la persistence des données
Exemple volume pour BDD
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 (ex :
.env.prod,config/prod.yml,...)- Utiliser plutôt des variables d'environnement (ex :
DATABASE_URLouDB_PASSWORD, c.f. 12 facteurs - III. Configuration - Stockez la configuration dans l’environnement)
- Utiliser plutôt des variables d'environnement (ex :
- Ne pas inclure le dossier
.gitdans les images :- Inclure
.gitdans.dockerignore - Éviter
COPY . .
- Inclure
- Ne pas utiliser
--build-argpour 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...
- Savoir que
- Veiller à ne pas inclure un fichier
.envincluant 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
.dockerignoreet.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).
- 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
Utiliser le fichier .dockerignore pour exclure les fichiers inutiles ou dangereux¶
- Ajouter un fichier
.dockerignorepour 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
Exemple installation ogr2ogr
# Supprimer les fichiers temporaires après installation des dépendances
RUN apt-get update
&& apt-get install -y gdal-bin \
&& rm -rf /var/lib/apt/lists/*
- Ordonner les instructions pour profiter de la mise en cache
CONTRE-EXEMPLE : ajout du code avant installation de package
# 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
Exemple : construire un site statique dans une image node, puis copier le résultat dans une image nginx
# 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
Exemple : installer les dépendances avant l'ajout du code
# 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
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
Exemple de Dockerfile avec HEALTHCHECK
# Ajouter un healthcheck
HEALTHCHECK CMD curl --fail http://localhost:8080/health || exit 1
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 : utiliser l'utilisateur node à l'exécution
# Exemple avec NodeJS où l'image inclue un utilisateur "node"
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
# uid=1000
USER node
CMD ["node", "app.js"]
- Prévoir des volumes avec les permissions adaptées dans l'image
Exemple : création d'un dossier /app/data
# 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 peut bloquer l'activation des options de sécurité runAsNonRoot: true et runAsUser: 1000 en contexte Kubernetes (ou induire des configurations supplémentaires, c.f. kubernetes.io - Safe and Unsafe Sysctls - net.ipv4.ip_unprivileged_port_start)
- 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¶
- Configurer les variables d'environnement avec des valeurs par défaut adaptées pour la production au niveau de l'image :
Exemple: Dockerfile avec APP_ENV=production, LOG_LEVEL=INFO,...
# 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 avec docker compose
Exemple: compose.yaml adapté au DEV
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¶
Pourquoi?
- Bloquer les modifications sur le code de l'application en cas d'attaque.
- Pouvoir se protéger contre un risque de full avec
emptyDir.sizeLimiten contexte Kubernetes.
Comment activer cette option?
- Pour Docker, voir docker run --read-only et read_only: true avec compose.
- Pour Kubernetes,
securityContext.readOnlyRootFilesystem: true
Pour permettre l'activation de cette option :
- Identifier les dossiers dynamiques pour lesquels il conviendra de monter des volumes (ex :
/app/data,/app/config,...) - Ne pas inclure du contenu statique dans ces dossiers dynamique
CONTRE-EXEMPLE : dossier /app/config mélangeant des données statiques et dynamiques
- Un fichier
/app/config/runtime.confest généré au démarrage - Un fichier
/app/config/static.confest inclu dans l'image 2
Scanner régulièrement les images¶
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¶
Principe
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¶
Mise en garde
Si vous utiliser docker sur une machine exposée, vous devrez éviter les expositions sur des ports (ex : -p 5432:5432 -> -p 127.0.0.1:5432:5432 si c'est vraiment nécessaire). 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)
- Via les IP des conteneurs (
- Utiliser un reverse proxy tel Traefik pour limiter le nombre de port à exposer et pour avoir de jolies URL (ex : https://opensearch.dev.localhost).
Robustesse¶
Utiliser un miroir pour l'accès aux images publiques¶
Mise en garde
Utiliser un miroir pour l'accès aux images DockerHub est important pour éviter d'atteindre la limite de pull sur DockerHub (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
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 600ne 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 de traitement SIGTERM avec Node.js et Express
/*
* 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àPENDINGpour 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 (approcheexec)
Cas particulier des images apache2¶
Pourquoi cette note?
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 faites pas ça :
# mauvaise pratique!
STOPSIGNAL SIGWINCH
- Adapter plutôt le signal à l'aide d'un script bash
Exemple de conversion SIGTERM en SIGWINCH
# 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
Divers¶
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
- Construire les images en spécifiant le proxy avec des arguments de construction
- Démarrer les conteneurs en spécifiant le proxy avec des variables d'environnement
Références¶
- docs.docker.com - Best practices for writing Dockerfiles
- Decouple applications : Empaqueter une seule application par conteneur
- Don’t install unnecessary packages : Installer uniquement les packages nécessaires dans les images
- Create ephemeral containers : Faire en sorte qu'un conteneur puisse être recréé sans perte de données (volumes nommés)
- Understand build context : Comprendre que les fichiers du contexte de construction sont envoyés au démon docker.
- Exclude with .dockerignore : Exclure des fichiers lors de la construction de l'image docker à l'aide de
.dockerignore - Use multi-stage builds
- Minimize the number of layers : Limiter le nombre de couches dans l'image en regroupant les commandes
- Leverage build cache : Comprendre les mécanismes de cache de construction pour mieux les exploiter
- Pipe Dockerfile through stdin : Envoyer directement le contenu
Dockerfileà via stdin quand son contenu est généré. - Sort multi-line arguments : Organiser les commandes sur plusieurs lignes pour faciliter la relecture et la maintenance
- cyber.gouv.fr - ANSSI - Recommandations de sécurité relatives au déploiement de conteneurs Docker qui aborde plus en détail les aspects systèmes que cette fiche où nous nous concentrons sur les éléments structurants dans la conception des applications et la création de conteneur.
-
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. ↩
-
Avec Kubernetes et l'option
readOnlyRootFilesystem: true, permettre la génération au démarrage d'un fichier/app/config/params.yamlsans vider/app/configsera délicat. En effet, un volumeemptyDirmonté sur/app/configne conservera pas le contenu original de l'image. ↩