Production
Ce qui sépare un conteneur qui marche d'un service exploitable.
Déploiement en production
Une image qui démarre correctement sur votre poste n’est pas une image prête pour la production, et l’écart entre les deux ne se comble pas par une commande de plus. Il se comble par une série de décisions que rien ne vous force à prendre — et c’est exactement pourquoi elles sont si souvent oubliées.
Les incidents qui en découlent ont une signature reconnaissable, parce qu’ils se répètent partout. Un serveur dont le disque atteint 100 % en trois semaines : personne n’avait configuré la rotation des journaux, et Docker les conserve sans limite par défaut. Un conteneur avec une fuite mémoire qui finit par faire tomber la base de données voisine, faute de plafond. Un déploiement qui coupe le service pendant quarante secondes parce que le processus ne reçoit jamais le signal d’arrêt. Un trafic redirigé vers un conteneur « démarré » qui n’accepte pas encore de connexions, parce que rien ne distinguait « lancé » de « prêt ». Et, plus discret, une image dont personne ne sait qu’elle embarque une bibliothèque vulnérable depuis huit mois.
Aucun de ces problèmes n’est difficile. Tous sont invisibles en développement, où le service tourne dix minutes, où la mémoire est abondante et où une coupure ne dérange personne. Ils n’apparaissent qu’avec la durée et la charge, c’est-à-dire au pire moment.
Cette dernière leçon parcourt ces décisions une par une. L’objectif n’est pas de transformer un serveur en cluster : Compose sur un hôte unique est une architecture parfaitement légitime pour beaucoup de services. L’objectif est que ce serveur tienne six mois sans intervention d’urgence.
Une image de production : minimale et épinglée
Une image plus petite se transfère plus vite, démarre plus vite et offre moins de code exploitable ; une image épinglée se reconstruit à l’identique dans six mois. Le multi-stage de la leçon 3 fait l’essentiel du travail ; il reste à choisir la base finale.
| Base | Taille indicative | Contenu | Diagnostic |
|---|---|---|---|
debian:12 | ~120 Mo | Distribution complète | Confortable |
python:3.12-slim | ~45 Mo | Debian réduite, glibc | Shell présent |
alpine:3.20 | ~8 Mo | musl au lieu de glibc | Shell sh |
distroless | ~20 Mo | Ni shell, ni gestionnaire de paquets | Aucun exec |
scratch | 0 | Rien du tout | Aucun |
Le choix n’est pas « le plus petit gagne ». Alpine impose musl, qui casse certains binaires précompilés et modifie le comportement de la résolution DNS ; les variantes -slim offrent souvent le meilleur rapport entre poids et tranquillité. Distroless et scratch donnent la plus petite surface d’attaque au prix du diagnostic : sans shell, docker exec ne sert plus à rien, et il faut compter entièrement sur les journaux et les métriques.
Pour l’épinglage, le tag ne suffit pas en production : un tag est mobile, un digest ne l’est pas.
# Un tag peut pointer vers un contenu different demain
FROM node:22-slim
# Un digest designe un contenu exact, pour toujours
FROM node:22-slim@sha256:d3e2b17e0f5a9b1d9c8f6e5a4b3c2d1e0f9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c
Le digest à épingler se lit avec docker inspect node:22-slim --format '{{index .RepoDigests 0}}'.
Enfin, si vos serveurs et vos postes n’ont pas la même architecture — cas courant depuis les Mac Apple Silicon — construisez des images multi-architectures avec docker buildx build --platform linux/amd64,linux/arm64 --push. Le --push n’est pas optionnel : une telle image est un index référençant plusieurs manifestes, structure que le cache local classique ne sait pas stocker.
Healthchecks : distinguer « démarré » de « prêt »
Sans healthcheck, Docker ne connaît qu’un seul état utile : le processus tourne, ou il ne tourne pas. Or un processus qui tourne peut être bloqué sur un verrou, avoir perdu sa connexion à la base, ou n’avoir pas fini de charger son cache.
# --interval : frequence des verifications en regime normal
# --timeout : au-dela, la verification compte comme un echec
# --start-period : periode de grace au demarrage, echecs non comptes
# --retries : echecs consecutifs avant le statut "unhealthy"
HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:3000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
Le choix de la commande de test compte. curl -f est le réflexe habituel, mais il suppose curl installé — faux sur une image minimale, et l’installer uniquement pour le healthcheck agrandit la surface d’attaque. Utiliser l’environnement d’exécution déjà présent, comme ci-dessus avec Node, évite ce compromis.
Le --start-period est le paramètre le plus souvent négligé et celui qui provoque le plus de faux positifs. Une application Java, ou une application qui applique des migrations au démarrage, peut mettre trente secondes à répondre : sans période de grâce, elle est déclarée unhealthy, redémarrée, et n’arrive jamais à démarrer. On observe une boucle de redémarrage sans cause apparente.
Côté application, l’endpoint doit vérifier quelque chose de réel. Une route qui répond 200 sans rien tester ne détecte aucune panne.
app.get('/health', async (req, res) => {
try {
await db.query('SELECT 1'); // la base repond-elle vraiment ?
await cache.ping(); // et le cache ?
res.status(200).json({ status: 'ok' });
} catch (err) {
res.status(503).json({ status: 'degraded', error: err.message });
}
});
docker ps # le statut de sante apparait dans la colonne STATUS
# Historique des verifications, avec la sortie de chaque tentative :
# indispensable pour comprendre POURQUOI un service est unhealthy
docker inspect --format '{{json .State.Health}}' mon-api
Un point d’honnêteté : Docker seul ne fait rien d’un conteneur unhealthy, il ne le redémarre pas. Le statut sert à Compose (condition: service_healthy), aux orchestrateurs, et à votre supervision — c’est elle qui doit alerter.
Limites de ressources : empêcher un conteneur d’en tuer les autres
Sans limite, un conteneur peut consommer toute la mémoire et tout le CPU de l’hôte. Le noyau finit par invoquer son tueur de processus, et rien ne garantit qu’il choisira le fautif : c’est souvent le processus le plus gourmand qui tombe, c’est-à-dire votre base de données.
docker run -d \
--memory 512m \ # plafond memoire strict
--memory-swap 512m \ # egal a --memory : interdit tout swap
--memory-reservation 256m \ # limite souple, appliquee sous pression
--cpus 1.5 \ # 1,5 cœur au maximum
--pids-limit 200 \ # nombre max de processus (anti fork bomb)
--restart unless-stopped \
mon-api:1.2.3
Le réglage de --memory-swap mérite une explication. S’il n’est pas précisé, Docker autorise un volume de swap égal à la limite mémoire : un processus qui fuit ralentit alors énormément la machine au lieu d’être arrêté. Fixer les deux à la même valeur interdit le swap et fait échouer franchement le conteneur — presque toujours préférable, car un échec net est détecté alors qu’une lenteur diffuse ne l’est pas.
En Compose, ces limites passent par deploy.resources, honoré par Compose v2 même hors cluster :
services:
api:
image: registre.exemple.com/equipe/mon-api:1.2.3
deploy:
resources:
limits:
cpus: "1.5"
memory: 512M
reservations:
memory: 256M
pids_limit: 200
restart: unless-stopped
Comment choisir les valeurs ? Observez la consommation en charge normale avec docker stats, puis ajoutez 50 à 100 % de marge. Et gardez en tête la signature d’un plafond trop bas : le code de sortie 137, soit 128 + 9, un SIGKILL envoyé par le noyau.
docker stats --no-stream --format 'table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}'
docker inspect mon-api --format '{{.State.ExitCode}} {{.State.OOMKilled}}'
Journalisation : la première cause de disque plein
Le pilote par défaut, json-file, n’applique aucune rotation. Une application bavarde écrit indéfiniment dans /var/lib/docker/containers/<id>/<id>-json.log. Sur un serveur qui tourne depuis six mois, ce fichier unique peut atteindre plusieurs dizaines de gigaoctets et remplir la partition — ce qui met à genoux tous les autres services de la machine.
Le correctif s’écrit par conteneur (--log-opt max-size=10m --log-opt max-file=3, soit trois fichiers de 10 Mo au maximum), mais la bonne pratique est de le poser une fois pour toutes au niveau du daemon, afin que le défaut soit sain pour tout ce qui sera lancé sur la machine.
// /etc/docker/daemon.json
{
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3"
},
"live-restore": true
}
Un sudo systemctl reload docker applique le fichier, et docker info --format '{{.LoggingDriver}}' confirme la prise en compte.
Le live-restore mérite d’être connu : il permet aux conteneurs de continuer à tourner pendant un redémarrage du daemon, ce qui rend les mises à jour de Docker beaucoup moins anxiogènes. Attention en revanche : la configuration du daemon ne s’applique qu’aux conteneurs créés après le rechargement, les existants gardent la leur.
Les autres pilotes répondent à des besoins de centralisation — local (plus compact), journald, syslog, fluentd, gelf, awslogs. Un point à connaître avant de basculer : avec la plupart d’entre eux, docker logs ne fonctionne plus, car le moteur ne conserve plus rien localement. Seuls json-file, local et journald le permettent.
Deux règles côté application, qui conditionnent l’utilité de tout le reste. Écrivez sur la sortie standard, jamais dans un fichier interne au conteneur. Et écrivez en JSON structuré plutôt qu’en texte libre : sur cinquante conteneurs, la différence entre des journaux exploitables et un mur de texte se joue là.
Arrêt propre : le détail qui cause les coupures de service
docker stop envoie SIGTERM au processus PID 1, attend dix secondes, puis envoie SIGKILL. Si votre application ne traite pas SIGTERM, chaque déploiement coupe brutalement les requêtes en cours. Trois causes possibles, trois correctifs.
Première cause : la forme shell de CMD. PID 1 est alors /bin/sh, qui ne relaie pas les signaux.
# PID 1 = sh. Votre application ne recoit JAMAIS SIGTERM.
CMD node server.js
# PID 1 = node. Le signal arrive.
CMD ["node", "server.js"]
Deuxième cause : l’application ne gère pas le signal.
process.on('SIGTERM', () => {
server.close(async () => { // plus de nouvelles connexions
await db.end(); // fermeture propre du pool
process.exit(0);
});
setTimeout(() => process.exit(1), 25_000); // filet de securite
});
Troisième cause : les processus zombies. Un vrai PID 1 doit récolter les processus orphelins, ce que la plupart des applications ne font pas. L’option --init insère un gestionnaire minimal (tini) qui s’en charge et relaie les signaux.
services:
api:
image: registre.exemple.com/equipe/mon-api:1.2.3
init: true
# Delai de grace : doit depasser la duree de votre arret propre
stop_grace_period: 30s
# Chronometrer l'arret. Proche de 10 s (ou de stop_grace_period) =>
# le signal n'est pas traite. Moins d'une seconde => c'est bon.
time docker stop mon-api
Registre privé : pousser et récupérer ses images
En production, on ne construit pas sur le serveur : on récupère une image déjà construite et testée. Cela suppose un registre.
# --password-stdin evite que le secret n'apparaisse dans
# l'historique du shell ou dans la sortie de "ps".
echo "$REGISTRY_TOKEN" | docker login registre.exemple.com \
-u ci-bot --password-stdin
docker tag mon-api:1.2.3 registre.exemple.com/equipe/mon-api:1.2.3
docker push registre.exemple.com/equipe/mon-api:1.2.3
docker logout registre.exemple.com
Une convention d’étiquetage à adopter dès le début : deux tags par construction, l’un lisible, l’autre traçable.
DEPOT="registre.exemple.com/equipe/mon-api"
docker build -t "$DEPOT:1.2.3" -t "$DEPOT:$(git rev-parse --short HEAD)" .
docker push --all-tags "$DEPOT"
Le tag de commit répond à la seule question qui compte à 3 heures du matin : quel code exact tourne en production ? Côté serveur, le déploiement se réduit alors à trois commandes.
docker compose pull # recuperer la nouvelle image
docker compose up -d # recreer les services dont l'image a change
docker compose ps
# Ne toucher qu'un service, sans redemarrer ses dependances
docker compose up -d --no-deps api
Soyons clairs sur les limites : cette opération recrée le conteneur, donc coupe brièvement le service. Un déploiement sans interruption demande deux instances derrière un répartiteur de charge, ce qui sort du périmètre de Compose seul. Pour la plupart des services, une coupure d’une à deux secondes est acceptable ; quand elle ne l’est pas, c’est le signal qu’un orchestrateur est devenu nécessaire.
Analyser les vulnérabilités et durcir l’exécution
Une image embarque des centaines de paquets dont vous n’avez choisi qu’une poignée. Des vulnérabilités y sont publiées en continu, y compris sur une image que vous n’avez pas modifiée depuis six mois — d’où la nécessité de reconstruire régulièrement, et pas seulement quand le code change.
# Synthese : nombre de vulnerabilites par niveau de gravite
docker scout quickview mon-api:1.2.3
# Detail, filtre sur les niveaux qui exigent une action
docker scout cves --only-severity critical,high mon-api:1.2.3
# Suggestions de mise a jour de l'image de base
docker scout recommendations mon-api:1.2.3
Trivy, outil libre très répandu, s’utilise en conteneur et convient bien à l’intégration continue. Un avertissement sur cette commande : monter /var/run/docker.sock donne au conteneur le contrôle complet du daemon, donc de la machine. Acceptable pour un outil choisi, sur un agent de build dédié ; jamais anodin.
# --exit-code 1 fait echouer la commande s'il reste des vulnerabilites
# du niveau demande : c'est ainsi qu'on bloque une chaine de livraison.
docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
aquasec/trivy:latest image \
--severity HIGH,CRITICAL --exit-code 1 \
mon-api:1.2.3
Le durcissement de l’exécution complète utilement l’analyse : ces options réduisent ce qu’un attaquant peut faire même en cas de faille applicative.
docker run -d \
--read-only \ # fichiers en lecture seule
--tmpfs /tmp:rw,noexec,nosuid,size=64m \ # sauf /tmp, en memoire
--cap-drop ALL \ # retirer toutes les capacites
--cap-add NET_BIND_SERVICE \ # puis rendre la seule utile
--security-opt no-new-privileges \ # pas d'elevation via setuid
--user 1001:1001 \ # jamais root
mon-api:1.2.3
Le --read-only est particulièrement efficace : il empêche un attaquant d’écrire un binaire dans le conteneur. Il exige simplement d’identifier les rares chemins que l’application doit pouvoir écrire, et de les monter séparément.
Une pile de production complète
Rassemblons tout dans un fichier utilisable en complément du compose.yaml de développement.
# compose.prod.yaml
# Usage : docker compose -f compose.yaml -f compose.prod.yaml up -d --wait
services:
api:
# Image du registre, epinglee : aucun build sur le serveur
image: registre.exemple.com/equipe/mon-api:${VERSION:?VERSION est obligatoire}
build: !reset null # neutralise le build herite du fichier de base
restart: unless-stopped
init: true
stop_grace_period: 30s
read_only: true
tmpfs:
- /tmp:size=64m
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
user: "1001:1001"
environment:
NODE_ENV: production
DATABASE_URL: postgres://app:${DB_PASSWORD:?}@db:5432/appdb
ports:
- "127.0.0.1:3000:3000" # un proxy en frontal fera l'exposition publique
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 30s
timeout: 3s
retries: 3
start_period: 20s
deploy:
resources:
limits:
cpus: "1.5"
memory: 512M
reservations:
memory: 256M
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
networks:
- frontend
- backend
db:
image: postgres:16-alpine
restart: unless-stopped
stop_grace_period: 1m # laisser Postgres finir son point de controle
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: ${DB_PASSWORD:?}
POSTGRES_DB: appdb
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
deploy:
resources:
limits:
memory: 1G
networks:
- backend # jamais joignable depuis l'exterieur
networks:
frontend:
backend:
internal: true
volumes:
db-data:
# Toujours verifier la fusion avant de deployer
docker compose -f compose.yaml -f compose.prod.yaml config
VERSION=1.2.3 docker compose -f compose.yaml -f compose.prod.yaml up -d --wait
Notez ${VERSION:?...} : un déploiement sans version explicite échoue immédiatement au lieu de partir silencieusement sur latest. Et restart: unless-stopped plutôt que always : la nuance est qu’un conteneur arrêté volontairement ne redémarre pas après un reboot du serveur, ce qui évite de réveiller un service mis hors ligne exprès.
Ce qu’il faut surveiller ensuite
Le déploiement n’est pas la fin. L’espace disque d’abord, car c’est la panne la plus fréquente. L’état de santé et le compteur de redémarrages ensuite, parce qu’une valeur qui grimpe signale un problème qu’aucune alerte ne remontera peut-être. La consommation par rapport aux limites fixées, enfin, pour détecter une dérive avant l’incident.
docker system df ; df -h /var/lib/docker
docker ps --format 'table {{.Names}}\t{{.Status}}'
docker inspect mon-api --format '{{.RestartCount}}'
Reste la fraîcheur des images : une image qui n’a pas été reconstruite depuis des mois accumule les vulnérabilités de ses dépendances même si votre code n’a pas bougé. Reconstruire et redéployer périodiquement, sans changement fonctionnel, est une pratique de maintenance, pas une perte de temps.
Le nettoyage automatisé demande enfin de la prudence. docker system prune -af sur un serveur supprime les images non utilisées, dont la version précédente vers laquelle vous voudriez revenir en cas de problème. Préférez une commande ciblée comme docker image prune -a --filter "until=720h" --force, qui ne touche que les images inutilisées depuis plus de trente jours.
À retenir
- Une image de production est minimale et épinglée : multi-stage, base
-slimou distroless selon le besoin de diagnostic, et référence par digest plutôt que par tag mobile. - Un healthcheck avec un
--start-periodréaliste distingue « démarré » de « prêt ». L’endpoint doit tester la base et le cache, sinon il ne détecte rien. Docker ne redémarre pas un conteneurunhealthy: c’est un signal pour Compose et pour votre supervision. - Fixez toujours
--memoryet--cpus. Sans plafond, une fuite mémoire dans un conteneur fait tomber ses voisins. Le code de sortie137signifie « tué pour dépassement mémoire ». - Configurez la rotation des journaux dans
/etc/docker/daemon.json, une fois pour toutes. Le défautjson-filesans rotation est la première cause de disque plein sur un serveur Docker. - Vérifiez que
docker stopprend moins d’une seconde. Sinon votre application ne traite pasSIGTERM— forme shell deCMD, ou gestionnaire de signal absent — et chaque déploiement coupe les requêtes en cours. - Ne construisez pas sur le serveur de production : poussez une image étiquetée à la fois par sa version et par son commit, puis
docker compose pull && docker compose up -d. - Analysez vos images (
docker scout cves, Trivy) et reconstruisez-les périodiquement même sans changement de code. Durcissez l’exécution avec--read-only,--cap-drop ALL,no-new-privilegeset un utilisateur non root.
Pour aller plus loin : quand un seul hôte ne suffit plus — haute disponibilité, déploiement progressif sans coupure, mise à l’échelle automatique — c’est le domaine des orchestrateurs. Tout ce que vous avez appris ici reste valable : Kubernetes consomme les mêmes images OCI et attend exactement les mêmes healthchecks, limites de ressources et journaux sur la sortie standard. La référence complète des options reste docs.docker.com.
Commentaires
Les commentaires sont alimentés par GitHub Discussions
Connectez-vous avec GitHub pour participer à la discussion