Éditer sur GitHub

Docker - Les bonnes pratiques

Cette annexe 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 où il n'est par exemple pas possible d'utiliser le port 80 (c.f. kubernetes.io - Pod Security Standards et kyverno/policies - baseline et restricted)

Les bonnes pratiques classiques

Empaqueter une seule application par conteneur

services:
  app:
    image: myapp:latest
    ports:
      - "5000:5000"
  db:
    image: postgres:15
    volumes:
      - pg-data:/var/lib/postgresql/data

volumes:
  pg-data:

(1) Il reste possible (et recommandé) d'inclure dans une même image un service et les utilitaires de ce service.

Faire en sorte qu'un conteneur puisse être recréé sans perte de 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

Utiliser le fichier .dockerignore pour exclure les fichiers inutiles ou dangereux

node_modules
.git
*.log
*.md
.dockerignore

Minimiser la taille des images

# 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/*
# 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/*
# 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

# 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

# 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

# Ajouter un healthcheck
HEALTHCHECK CMD curl --fail http://localhost:8080/health || exit 1

(1) 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 côté Kubernetes.

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.

Ne pas exécuter les conteneurs en tant qu'utilisateur root

# 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"]
# 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

L'utilisation du port 80 dans de nombreuses images bloque l'activation de runAsNonRoot: true et runAsUser: 1000 avec de nombreuses images en contexte Kubernetes.

Configurer les variables d'environnement avec des valeurs par défaut pour la configuration

ENV APP_ENV=production
ENV APP_DEBUG=false
ENV LOG_LEVEL=inf
# ...

Permettre l'exécution des conteneurs avec un système de fichiers en lecture seule

Avec Kubernetes, une option readOnlyRootFilesystem: true permet d'avoir un conteneur avec un système de fichier en lecture seule présentant différents avantages :

Pour permettre son utilisation sur une image que l'on met à disposition :

Scanner régulièrement les images pour détecter des failles ou des secrets

A date (juin 2024), la difficulté de l'exploitation des alertes tient à la présence de failles jugées critiques par ces outils dans les images officielles massivement utilisées (ex : trivy image --severity HIGH,CRITICAL debian:12-slim)

triyv image mon-image:latest

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.

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 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 ) :

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é :

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 600 ne 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.

/*
 * 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,...).

(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 (approche exec)

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...

STOPSIGNAL SIGWINCH

Ne pas modifier le signal d'arrêt par défaut

# mauvaise pratique!
STOPSIGNAL 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

Les bonnes pratiques spécifiques à la contrainte d'utilisation d'un proxy sortant

Ne pas gérer un proxy sortant dans le Dockerfile

# MAUVAISE PRATIQUE :
ENV HTTP_PROXY=http://proxy.devinez.fr:3128
ENV HTTPS_PROXY=http://proxy.devinez.fr:3128

Références