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/datapour 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_URLouDB_PASSWORD, c.f. 12 facteurs - III. Configuration - Stockez la configuration dans l’environnement) - Ne pas inclure le dossier
.gitdans les images : - Inclure
.gitdans.dockerignore. - Éviter
COPY . .. - 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...
- 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).
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 :
# 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.confest généré au démarrage et/app/config/static.confest 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.yamlsans vider/app/configsera délicat. Contrairement au cas docker où l'on utilisera un volume nommé, un volumeemptyDirmonté sur/app/configne 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 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 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¶
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¶
- cloud.google.com - Bonnes pratiques en matière de création de conteneurs
- Traiter correctement le PID 1, le traitement de signal et les processus zombie
- Optimiser les conteneurs pour le cache de création de Docker
- Supprimer les outils inutiles
- Créer la plus petite image possible
- Utiliser l'analyse des failles dans Container Registry
- Ajouter des tags pertinents aux images
- Envisager sérieusement l'utilisation d'une image publique
- 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. ↩