Livraison continue et déploiement continu
Pourquoi
À la fin de la leçon 3, le pipeline dit, pour chaque commit, si le code est correct. Mais du code correct dans un dépôt ne rend service à personne : il faut encore le mettre en production. Dans beaucoup d'organisations, ce dernier kilomètre reste artisanal. Une personne habilitée se connecte au serveur un soir par mois, suit une procédure de vingt étapes, reconstruit l'application « à partir de la bonne branche », modifie deux fichiers de configuration à la main, et croise les doigts. Les mises en production sont rares parce qu'elles sont risquées, et risquées parce qu'elles sont rares : chacune embarque un mois de changements, et la procédure, peu pratiquée, se passe rarement deux fois de la même façon.
La livraison continue applique au déploiement le raisonnement que l'intégration continue applique à la fusion : puisque c'est douloureux, on le fait souvent, et pour pouvoir le faire souvent, on l'automatise entièrement. Le pipeline ne s'arrête plus aux tests : il produit l'artefact qui sera livré, le teste tel quel, le déploie dans un environnement de recette, et le tient prêt pour la production.
Cette leçon prolonge notre serveur de CI jusqu'à deux environnements, recette et production, sur votre machine. C'est la dernière pièce : à la fin, un git push sur main amène le code jusqu'à un environnement qui tourne, et un seul geste l'amène en production.
Les concepts
Trois termes, trois promesses
Les deux « CD » se confondent souvent ; ils ne promettent pas la même chose :
| Intégration continue | Livraison continue | Déploiement continu | |
|---|---|---|---|
| Promesse | Le code de main est intégré et vérifié en permanence | main est déployable à tout moment | Chaque changement de main est déployé |
| Dernière étape automatique | Tests | Déploiement en recette, artefact prêt | Déploiement en production |
| Mise en production | Hors sujet | Décision humaine, d'un geste | Automatique |
Martin Fowler résume la livraison continue en une phrase : une discipline dans laquelle on construit le logiciel de façon à pouvoir le mettre en production à tout moment. Il en donne quatre signes : le logiciel est déployable tout au long de sa vie ; l'équipe fait passer le maintien de cette propriété avant les nouvelles fonctionnalités ; chacun obtient un retour rapide et automatique sur l'aptitude à la production à chaque changement ; on peut déployer n'importe quelle version dans n'importe quel environnement d'un simple geste.
Le déploiement continu va un cran plus loin : plus de geste, chaque commit qui passe le pipeline part en production. Le terme a été popularisé en 2009 par Timothy Fitz, qui décrivait chez IMVU une cinquantaine de mises en production par jour. Il suppose la livraison continue, l'inverse n'est pas vrai. Beaucoup d'équipes s'arrêtent volontairement à la livraison continue, pour des raisons qui ne sont pas techniques : fenêtre de maintenance contractuelle avec un client, validation réglementaire, coordination avec une campagne de communication. Le geste final devient alors une décision métier, et non plus une prouesse technique.
Construire une seule fois
Le premier principe de la livraison continue, chez Humble et Farley, est de construire l'artefact une seule fois, puis de faire circuler ce même artefact d'environnement en environnement. On ne reconstruit pas « la même version » pour la production : une reconstruction, même à partir du même commit, peut embarquer une dépendance publiée entre-temps, une image de base mise à jour, un outil de construction différent. Ce qui a été testé en recette ne serait plus ce qui tourne en production.
Corollaire : l'artefact doit être désigné sans ambiguïté. Une étiquette d'image comme signalements:1.4 ou signalements:latest est un nom mobile, qu'une nouvelle publication peut déplacer. L'empreinte (signalements@sha256:1780e5...), calculée à partir du contenu, ne désigne qu'un contenu et un seul (cours Docker : les fondamentaux, leçon 7). On déploie par empreinte.
L'artefact et la configuration
Si l'on déploie le même artefact partout, ce qui diffère entre la recette et la production doit être hors de l'artefact : adresse de la base, mots de passe, nom de l'environnement, nombre de processus. La méthode Twelve-Factor App le formule en deux principes : la configuration vient de l'environnement d'exécution, pas du code ; et l'on sépare strictement trois étapes, la construction (le code devient un artefact), la publication (release : l'artefact est associé à une configuration) et l'exécution. Une publication porte un identifiant unique et ne se modifie pas : tout changement en crée une nouvelle, et revenir en arrière consiste à réexécuter une publication précédente.
flowchart LR
C["Commit"] --> B["Construction<br/>une seule fois"]
B --> A[("Artefact<br/>signalements@sha256:...")]
A --> R["Recette<br/>+ config recette"]
A --> P["Production<br/>+ config production"]
R -. "même empreinte" .- P
Ce qui appartient à l'artefact, en revanche, y est figé à la construction : le code, les dépendances, et la version elle-même. Une application qui affiche sa version sans la tirer de l'artefact ment tôt ou tard.
Le pipeline de déploiement
Humble et Farley appellent pipeline de déploiement la suite d'étapes qui va du commit à la production, chaque étape étant plus coûteuse, plus proche de la production, et donnant plus de confiance que la précédente. Un artefact qui échoue à une étape ne va pas plus loin. Notre pipeline devient :
| Étape | Ce qu'elle vérifie | Sur quoi |
|---|---|---|
| format, lint, unitaires | Le code est correct en isolation | Les sources |
| image | L'artefact se construit ; il est publié et identifié | Les sources, une fois |
| integration | Le code dialogue correctement avec PostgreSQL | Les sources et un service |
| fumee | L'artefact démarre et répond | L'image publiée |
| deployer recette | L'artefact se déploie avec une configuration réelle | L'image publiée, configuration de recette |
| (geste humain) production | La même image, configuration de production |
Déployer n'est pas mettre à disposition
Dernière distinction, qui libère beaucoup d'équipes : déployer (installer une nouvelle version sur les serveurs) et mettre à disposition (release : rendre une fonctionnalité visible des utilisateurs) sont deux actes différents. Avec un drapeau de fonctionnalité, on déploie le lundi un code dont la fonctionnalité reste désactivée, et on l'active le jeudi, pour 5 % des utilisateurs d'abord. Le déploiement devient un non-événement technique, et la mise à disposition une décision produit, réversible en un clic. Les cours Stratégies de déploiement et Feature flags du chapitre développent ces techniques.
En pratique
Un registre et deux environnements
L'artefact sera une image de conteneur, publiée dans un registre local (comme dans le cours Construire des images de conteneurs) :
$ docker run -d --name registre-ci -p 127.0.0.1:5000:5000 registry:3
Côté application, ajoutez à Signalements le Dockerfile des cours précédents, avec une différence : la version est passée à la construction et figée dans l'image.
# syntax=docker/dockerfile:1
FROM python:3.14-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
PIP_ROOT_USER_ACTION=ignore
RUN useradd --system --uid 10001 --no-create-home --shell /usr/sbin/nologin appli
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY app.py .
# La version fait partie de l'artefact : elle est fixée à la construction.
ARG VERSION=dev
ENV APP_VERSION=$VERSION
USER 10001
EXPOSE 8000
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "--no-control-socket", "app:app"]Et un .dockerignore (.git, __pycache__, *.xml, .coverage) pour ne pas envoyer les restes des étapes de tests dans le contexte de construction.
La description d'un environnement va dans deploiement/compose.yaml. Elle est versionnée avec le code, mais ne contient aucune valeur propre à un environnement : l'image, le port et le mot de passe sont des variables, et ${VARIABLE:?} fait échouer Compose si l'une manque.
# Description d'un environnement de Signalements. L'image (par empreinte), le port
# et le mot de passe sont fournis par le serveur de déploiement, jamais par le dépôt.
services:
app:
image: ${IMAGE:?image à déployer}
environment:
DATABASE_URL: postgresql://signalements:${POSTGRES_PASSWORD:?}@db:5432/signalements
ports:
- "127.0.0.1:${PORT:?}:8000"
depends_on:
db:
condition: service_healthy
read_only: true
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/sante', timeout=2)"]
interval: 5s
timeout: 3s
retries: 3
start_period: 10s
restart: unless-stopped
db:
image: postgres:18.6
environment:
POSTGRES_USER: signalements
POSTGRES_DB: signalements
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?}
volumes:
- pgdata:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U signalements -d signalements -h 127.0.0.1"]
interval: 5s
timeout: 3s
retries: 5
start_period: 30s
restart: unless-stopped
volumes:
pgdata:La configuration propre à chaque environnement vit sur le serveur de déploiement, hors du dépôt, lisible par lui seul :
$ mkdir serveur-ci/environnements
$ printf 'PORT=8081\nPOSTGRES_PASSWORD=%s\n' "$(openssl rand -hex 16)" > serveur-ci/environnements/recette.env
$ printf 'PORT=8082\nPOSTGRES_PASSWORD=%s\n' "$(openssl rand -hex 16)" > serveur-ci/environnements/production.env
$ chmod 600 serveur-ci/environnements/*.env
Le script de déploiement
serveur-ci/deployer prend un numéro d'exécution et un environnement. Il déploie l'artefact de cette exécution, jamais une reconstruction :
#!/usr/bin/env bash
# Déploie l'artefact d'une exécution dans un environnement.
# Usage : deployer <numéro d'exécution> <environnement>
set -euo pipefail
base=$(cd "$(dirname "$0")" && pwd)
numero=$1 environnement=$2
travail="$base/travaux/$numero"
config="$base/environnements/$environnement.env"
[ -f "$config" ] || { echo "environnement inconnu : $environnement" >&2; exit 1; }
[ -f "$travail/artefact" ] || { echo "l'exécution n°$numero n'a pas produit d'artefact" >&2; exit 1; }
image=$(cat "$travail/artefact")
read -r commit ref < "$travail/commit"
# La même image, une configuration propre à l'environnement
IMAGE="$image" docker compose --project-name "signalements-$environnement" \
--env-file "$config" -f "$travail/src/deploiement/compose.yaml" up -d --wait --quiet-pull
# Le registre des déploiements : qui, quoi, où, quand
echo "$(date --iso-8601=seconds) $environnement n°$numero ${commit:0:12} $image" >> "$base/deploiements.log"
echo "$environnement : ${image#*@} (commit ${commit:0:7}, exécution n°$numero)"--project-name "signalements-$environnement": chaque environnement est un projet Compose distinct, avec ses conteneurs, son réseau et son volume de données.--env-file "$config"fournit les variables de l'environnement.IMAGE, passée dans l'environnement du processus, est prioritaire sur le fichier.up -d --waitne rend la main que lorsque les vérifications de santé passent, et échoue sinon : un déploiement qui ne démarre pas est un déploiement en échec.deploiements.loggarde la trace de chaque déploiement. Elle servira à revenir en arrière, et à mesurer, à la leçon 6.
L'orchestrateur sait construire et déployer
executer reconnaît deux nouveaux types de lignes dans .pipeline. Sa boucle est réorganisée en un case par type d'étape. Voici la nouvelle version complète, dont les ajouts sont l'étape image, l'étape deployer, le fichier commit et la référence @artefact dans les services :
#!/usr/bin/env bash
# Exécute le pipeline d'un commit. Usage : executer <commit> <référence>
# Appelé par le crochet post-receive du dépôt, depuis le dépôt nu.
set -uo pipefail
commit=$1 ref=$2
base=$(cd "$(dirname "$0")" && pwd)
registre=localhost:5000
# 1. Un numéro et un espace de travail neuf pour chaque exécution
numero=$(( $(cat "$base/compteur" 2>/dev/null || echo 0) + 1 ))
echo "$numero" > "$base/compteur"
travail="$base/travaux/$numero"
mkdir -p "$travail/src" "$travail/journaux" "$base/cache/pip"
git archive "$commit" | tar -x -C "$travail/src"
echo "$commit $ref" > "$travail/commit"
echo "pipeline n°$numero : ${ref#refs/heads/} @ ${commit:0:7}"
# Un réseau privé par exécution, supprimé à la fin avec les services
reseau="ci-$numero"
docker network create "$reseau" > /dev/null
nettoyer() {
docker rm -f $(docker ps -aq --filter "label=ci.execution=$numero") > /dev/null 2>&1
docker network rm "$reseau" > /dev/null
}
trap nettoyer EXIT
# 2. La définition du pipeline vient du dépôt lui-même
if [ ! -f "$travail/src/.pipeline" ]; then
echo " pas de fichier .pipeline : rien à exécuter"
exit 0
fi
# 3. Les étapes, dans l'ordre ; la première qui échoue arrête tout
statut=succes
while read -r etape image commande; do
case "$etape" in ''|'#'*) continue ;; esac
debut=$(date +%s)
case "$etape" in
service) # service <nom> <image|@artefact> [VARIABLE=valeur...]
nom=$image; set -- $commande; image=$1; shift
[ "$image" = @artefact ] && image=$(cat "$travail/artefact")
env=(); for variable in "$@"; do env+=(-e "$variable"); done
docker run -d --label "ci.execution=$numero" --network "$reseau" \
--network-alias "$nom" "${env[@]}" "$image" > /dev/null
echo " ... service $nom démarré"
continue ;;
image) # image <nom> : construire l'artefact, le publier, noter son empreinte
cible="$registre/$image:${commit:0:12}"
if docker build --quiet --build-arg VERSION="${commit:0:7}" -t "$cible" "$travail/src" \
> "$travail/journaux/image.log" 2>&1 \
&& docker push "$cible" >> "$travail/journaux/image.log" 2>&1; then
empreinte=$(docker inspect --format '{{index .RepoDigests 0}}' "$cible")
echo "$empreinte" > "$travail/artefact"
echo " OK image ($(( $(date +%s) - debut )) s) ${empreinte#*@}"
continue
fi ;;
deployer) # deployer <environnement> : seulement depuis main
if [ "$ref" != refs/heads/main ]; then
echo " -- deployer $image ignoré (branche ${ref#refs/heads/})"
continue
fi
if "$base/deployer" "$numero" "$image" > "$travail/journaux/deployer-$image.log" 2>&1; then
echo " OK deployer $image ($(( $(date +%s) - debut )) s)"
continue
fi
etape="deployer-$image" ;;
*) # étape ordinaire, dans un conteneur jetable
if docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp \
-e PIP_CACHE_DIR=/cache/pip -e PIP_DISABLE_PIP_VERSION_CHECK=1 \
--network "$reseau" -v "$base/cache":/cache -v "$travail/src":/src -w /src \
"$image" sh -c "$commande" > "$travail/journaux/$etape.log" 2>&1 < /dev/null
then
echo " OK $etape ($(( $(date +%s) - debut )) s)"
continue
fi ;;
esac
# On n'arrive ici qu'en cas d'échec
echo " ÉCHEC $etape ($(( $(date +%s) - debut )) s), fin du journal :"
tail -n 5 "$travail/journaux/$etape.log" | sed 's/^/ /'
statut=echec
break
done < "$travail/src/.pipeline"
# 4. Le statut est enregistré, lié au commit
echo "$statut $commit $ref" > "$travail/statut"
echo "résultat : $statut (journaux dans $travail/journaux)"
# Code de sortie : 0 si le pipeline a réussi
[ "$statut" = succes ]Trois points méritent l'attention :
- L'étape
imagenote l'empreinte renvoyée par le registre danstravaux/<n>/artefact. C'est la seule désignation de l'artefact qui circule ensuite : le service de test, le déploiement en recette et la promotion en production la lisent tous au même endroit. - L'étiquette
${commit:0:12}n'est qu'une commodité pour un humain qui parcourt le registre ; rien ne l'utilise pour déployer. deployerne s'exécute que pourmain. Une branche construit et teste son artefact, mais ne le déploie nulle part. C'est l'équivalent deif: github.ref == 'refs/heads/main'dans un workflow GitHub Actions.
Le pipeline complet
Le test de fumée de l'exercice 3 de la leçon 3 accepte maintenant une adresse en argument (url = sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:8000/sante"), pour pouvoir viser l'application démarrée comme service. Le .pipeline devient :
# étape image / nom commande
format python:3.14-slim pip install --user --quiet ruff==0.16.9 && python -m ruff format --check --no-cache .
lint python:3.14-slim pip install --user --quiet ruff==0.16.9 && python -m ruff check --no-cache .
unitaires python:3.14-slim pip install --user --quiet -r requirements-dev.txt && python -m pytest -p no:cacheprovider --cov=app --junitxml=rapport-unitaires.xml
image signalements
service db postgres:18.6 POSTGRES_PASSWORD=ci
integration python:3.14-slim export DATABASE_URL=postgresql://postgres:ci@db/postgres && pip install --user --quiet -r requirements-dev.txt && python ci/attendre_postgres.py && python -m pytest -q -p no:cacheprovider --cov=app --junitxml=rapport-integration.xml test_integration.py
service app @artefact DATABASE_URL=postgresql://postgres:ci@db/postgres
fumee python:3.14-slim python ci/fumee.py http://app:8000/sante
deployer recetteL'image est construite dès que le code a passé les vérifications rapides. Le service app démarre l'artefact lui-même, après l'étape d'intégration qui a attendu que PostgreSQL soit prêt (l'application crée sa table au démarrage, et s'arrêterait si la base ne répondait pas).
Première livraison, premier échec instructif
$ git add -A && git commit -m "Livraison : image, test de fumée de l'artefact, déploiement en recette"
$ git push -q ci main
remote: pipeline n°15 : main @ 2027bdb
remote: OK format (2 s)
remote: OK lint (2 s)
remote: OK unitaires (6 s)
remote: OK image (4 s) sha256:3448c1621fe3935a1de576236ed87a2bea1ffc701b1244f2d333c40940223988
remote: ... service db démarré
remote: OK integration (5 s)
remote: ... service app démarré
remote: OK fumee (1 s)
remote: ÉCHEC deployer-recette (7 s), fin du journal :
remote: Container signalements-recette-app-1 Started
remote: Container signalements-recette-app-1 Waiting
remote: Container signalements-recette-db-1 Waiting
remote: Container signalements-recette-db-1 Healthy
remote: container signalements-recette-app-1 is unhealthy
remote: résultat : echec (journaux dans /home/.../serveur-ci/travaux/15/journaux)
Le test de fumée a réussi, et pourtant le déploiement échoue : l'application de recette n'est jamais déclarée en bonne santé. Ses journaux disent pourquoi :
$ docker logs signalements-recette-app-1 2>&1 | tail -4
File "/usr/local/lib/python3.14/tempfile.py", line 222, in _get_default_tempdir
raise FileNotFoundError(_errno.ENOENT,
"No usable temporary directory found in %s" %
dirlist)
FileNotFoundError: [Errno 2] No usable temporary directory found in ['/tmp', '/var/tmp', '/usr/tmp', '/app']
En recette, le conteneur a une racine en lecture seule (read_only: true), comme il se doit en production ; gunicorn a besoin d'un répertoire temporaire inscriptible pour ses processus, et n'en trouve aucun. Le service app du pipeline, lui, tournait sans cette restriction : il ne ressemblait pas assez à la production pour voir le problème. C'est exactement pour cela que Fowler range parmi les pratiques de l'intégration continue le fait de tester dans un clone de l'environnement de production, et que la recette existe : le défaut a été arrêté là, la production n'a rien vu. On ajoute un tmpfs (cours Docker : les fondamentaux, leçon 12) :
read_only: true
tmpfs:
- /tmp:size=16m$ git commit -am "Déploiement : /tmp en tmpfs pour gunicorn avec une racine en lecture seule"
$ git push -q ci main
remote: pipeline n°16 : main @ 7524db9
remote: OK format (2 s)
remote: OK lint (3 s)
remote: OK unitaires (5 s)
remote: OK image (2 s) sha256:1780e58b3fe8a675bc44d59476c522a62cd98b8006d6c2a3bda3068eb7425263
remote: ... service db démarré
remote: OK integration (7 s)
remote: ... service app démarré
remote: OK fumee (1 s)
remote: OK deployer recette (7 s)
remote: résultat : succes (journaux dans /home/.../serveur-ci/travaux/16/journaux)
$ curl -s localhost:8081/
{"application":"signalements","conteneur":"7c5f209ff6c3","stockage":"postgresql","version":"7524db9"}
Vingt-sept secondes après le git push, la nouvelle version tourne en recette, et elle affiche le commit dont elle est issue. Notez la conséquence de l'épisode : il faudrait que le service app du pipeline tourne avec les mêmes restrictions que la production (exercice 2).
Promouvoir en production
La livraison continue s'arrête ici : l'artefact n°16 est prêt. La mise en production est un geste humain, une seule commande, qui reprend le même artefact :
$ serveur-ci/deployer 16 production
...
Container signalements-production-db-1 Healthy
Container signalements-production-app-1 Healthy
production : sha256:1780e58b3fe8a675bc44d59476c522a62cd98b8006d6c2a3bda3068eb7425263 (commit 7524db9, exécution n°16)
$ for e in recette production; do docker inspect --format "$e {{.Config.Image}}" signalements-$e-app-1; done
recette localhost:5000/signalements@sha256:1780e58b3fe8a675bc44d59476c522a62cd98b8006d6c2a3bda3068eb7425263
production localhost:5000/signalements@sha256:1780e58b3fe8a675bc44d59476c522a62cd98b8006d6c2a3bda3068eb7425263
Les deux environnements exécutent une image identique, octet pour octet. Ce qui a été testé en recette est ce qui tourne en production.
Pourquoi ne pas simplement reconstruire pour la production ? Faisons l'essai, à partir des sources exactes du commit 7524db9, avec le même argument de version :
$ docker image inspect --format '{{.Id}}' localhost:5000/signalements:7524db925c94
sha256:1780e58b3fe8a675bc44d59476c522a62cd98b8006d6c2a3bda3068eb7425263
$ docker build --quiet --no-cache --build-arg VERSION=7524db9 -t signalements:reconstruite serveur-ci/travaux/16/src
sha256:b21f9be52333edf17bd074c85caee5731024b3d983c1c3e9d394cc68b3109306
Même commit, même Dockerfile, autre image : les dates de création des couches diffèrent, et rien n'empêche que pip ou l'image de base aient changé entre-temps (le cours Construire des images de conteneurs montre comment rendre une construction reproductible, ce qui est une garantie supplémentaire, pas un substitut). Une image reconstruite n'est pas l'image testée.
Une nouvelle version, et la configuration par environnement
Faisons afficher à l'application le nom de l'environnement où elle tourne. Ce nom est de la configuration : il ne doit pas être dans l'image, puisque la même image tourne dans les deux environnements.
version=os.environ.get("APP_VERSION", "dev"),
+ environnement=os.environ.get("ENVIRONNEMENT", "local"),
DATABASE_URL: postgresql://signalements:${POSTGRES_PASSWORD:?}@db:5432/signalements
+ ENVIRONNEMENT: ${ENVIRONNEMENT:?nom de l'environnement}
Côté serveur, chaque fichier d'environnement reçoit sa valeur, puis on pousse :
$ echo ENVIRONNEMENT=recette >> serveur-ci/environnements/recette.env
$ echo ENVIRONNEMENT=production >> serveur-ci/environnements/production.env
$ git commit -am "Accueil : afficher le nom de l'environnement" && git push -q ci main
remote: pipeline n°17 : main @ 1bc58a6
...
remote: OK image (2 s) sha256:965b6251693063fae196999fd8852a8f0b52f9fb027bcdee7f68ccee1b268f91
...
remote: OK deployer recette (8 s)
$ curl -s localhost:8081/
{"application":"signalements","conteneur":"9cf1089b5930","environnement":"recette","stockage":"postgresql","version":"1bc58a6"}
$ curl -s localhost:8082/
{"application":"signalements","conteneur":"f01a0be71998","stockage":"postgresql","version":"7524db9"}
La recette a la nouvelle version ; la production attend la décision. On la prend, et un utilisateur crée aussitôt un signalement :
$ serveur-ci/deployer 17 production | tail -1
production : sha256:965b6251693063fae196999fd8852a8f0b52f9fb027bcdee7f68ccee1b268f91 (commit 1bc58a6, exécution n°17)
$ curl -s localhost:8082/
{"application":"signalements","conteneur":"628c172cf058","environnement":"production","stockage":"postgresql","version":"1bc58a6"}
$ curl -s -X POST localhost:8082/signalements -H 'Content-Type: application/json' \
-d '{"lieu":"Rue Pasteur","description":"Feu tricolore en panne"}'
{"description":"Feu tricolore en panne","id":1,"lieu":"Rue Pasteur"}
Même image qu'en recette, configuration différente : "environnement":"production".
Revenir en arrière
Supposons que la version 17 pose un problème en production. Revenir en arrière, c'est redéployer l'artefact précédent, déjà construit, déjà testé, toujours dans le registre :
$ time serveur-ci/deployer 16 production | tail -1
production : sha256:1780e58b3fe8a675bc44d59476c522a62cd98b8006d6c2a3bda3068eb7425263 (commit 7524db9, exécution n°16)
real 0m7.185s
$ curl -s localhost:8082/
{"application":"signalements","conteneur":"3fbfc38dbbed","stockage":"postgresql","version":"7524db9"}
$ curl -s localhost:8082/signalements
[{"description":"Feu tricolore en panne","id":1,"lieu":"Rue Pasteur"}]
Sept secondes, sans reconstruction ni procédure. Les données sont intactes : elles vivent dans le volume de l'environnement, pas dans l'artefact. Le registre des déploiements garde toute l'histoire :
$ cat serveur-ci/deploiements.log
2026-10-01T16:59:47+02:00 recette n°16 7524db925c94 localhost:5000/signalements@sha256:1780e58b...
2026-10-01T17:00:17+02:00 production n°16 7524db925c94 localhost:5000/signalements@sha256:1780e58b...
2026-10-01T17:00:53+02:00 recette n°17 1bc58a6238d5 localhost:5000/signalements@sha256:965b6251...
2026-10-01T17:01:06+02:00 production n°17 1bc58a6238d5 localhost:5000/signalements@sha256:965b6251...
2026-10-01T17:01:13+02:00 production n°16 7524db925c94 localhost:5000/signalements@sha256:1780e58b...
(Les empreintes sont tronquées ici pour la lisibilité ; le fichier contient les empreintes complètes.)
Une branche ne déploie pas
$ git switch -c essai-livraison && git commit --allow-empty -m "Essai sur une branche"
$ git push -q ci essai-livraison
remote: OK image (1 s) sha256:b4ae030984103f8d45e14c7ced389d9b18e83fcbfe41601b3b894ff302efde30
remote: -- deployer recette ignoré (branche essai-livraison)
remote: résultat : succes (journaux dans /home/.../serveur-ci/travaux/18/journaux)
L'artefact de la branche est construit et testé, mais la recette continue de refléter main.
Sous le capot
Ce que contient une empreinte. docker push envoie au registre les couches, la configuration et le manifeste de l'image, puis le registre renvoie l'empreinte SHA-256 de ce manifeste. Le manifeste liste lui-même les empreintes de la configuration et de chaque couche : l'empreinte du manifeste engage donc tout le contenu, par une chaîne d'empreintes. Une référence registre/nom@sha256:... est vérifiée par le client au téléchargement : si le registre renvoyait autre chose, l'empreinte ne correspondrait pas et le téléchargement échouerait. C'est ce qui rend le déploiement par empreinte sûr même avec un registre auquel on ne fait pas entièrement confiance.
Pourquoi Compose ne redémarre que ce qui change. docker compose up compare la configuration demandée à celle des conteneurs existants (Compose enregistre une empreinte de la configuration de chaque service dans une étiquette du conteneur). Lors de la promotion, seule l'image de app change : Compose recrée le conteneur app et laisse db tel quel. C'est pourquoi la base, et ses données, survivent aux déploiements et aux retours arrière.
Déploiement poussé ou tiré. Notre serveur pousse le déploiement : il exécute lui-même docker compose sur la machine cible, donc il en détient les accès. L'autre modèle consiste à tirer : le pipeline se contente d'écrire, dans un dépôt Git, quelle empreinte doit tourner où, et un agent installé dans l'environnement cible observe ce dépôt et applique les changements. C'est le principe de GitOps, et c'est ainsi qu'est déployé le site que vous lisez : le dernier travail de son workflow GitHub Actions écrit l'étiquette main-<commit> dans deploy/chart/values.yaml et la commite, puis Argo CD, dans le cluster Kapsule, aligne le déploiement sur ce fichier. Le pipeline n'a aucun accès au cluster. Le cours GitOps avec Argo CD est consacré à ce modèle.
Pièges courants
Reconstruire pour la production. Démontré plus haut : une reconstruction n'est pas l'artefact testé. Le pipeline produit l'artefact ; tous les environnements le consomment.
Déployer par étiquette mobile. image: signalements:latest dans un fichier de déploiement signifie « ce que le registre appellera latest au moment où le conteneur redémarrera », c'est-à-dire peut-être une autre version, des semaines plus tard, sans que personne ait rien décidé. Déployez par empreinte.
Une recette qui ne ressemble pas à la production. Démontré aussi : read_only, limites mémoire, utilisateur non-root, version de PostgreSQL, volumétrie... chaque différence est un défaut qui ne se révélera qu'en production. Utilisez la même description de déploiement, et ne faites varier que la configuration.
Une configuration dans l'image. Un fichier config-production.py copié dans l'image oblige à construire une image par environnement, et l'on perd le principe de l'artefact unique. Pire, il y met souvent des secrets.
Oublier la base de données au retour arrière. Le retour arrière de cette leçon est simple parce que les versions 16 et 17 utilisent le même schéma. Si la version 17 avait renommé une colonne, la version 16 ne saurait plus lire la base. La technique du changement parallèle (parallel change, ou expand and contract) découpe un changement de schéma en étapes compatibles : ajouter la nouvelle colonne, écrire dans les deux, migrer les données, lire la nouvelle, et seulement plusieurs versions plus tard supprimer l'ancienne. Chaque version reste ainsi compatible avec la précédente, dans les deux sens.
Sécurité
- Le pipeline qui déploie détient les clés de la production. Notre serveur peut lancer n'importe quel conteneur sur la machine des environnements : quiconque contrôle ce qu'il exécute contrôle la production. Le modèle tiré (GitOps) réduit ce risque en retirant au pipeline tout accès aux environnements. La leçon 5 traite ce point en détail.
- Les secrets restent hors du dépôt et hors de l'image. Les mots de passe sont dans
environnements/*.env, en mode600, sur le serveur de déploiement. En production réelle, on utilise un gestionnaire de secrets (Secret Manager de Scaleway, Vault, secrets Kubernetes chiffrés), et l'on ne les écrit jamais dans les journaux. - Toutes les branches publient dans le même registre. L'artefact de la branche
essai-livraisonest dans le registre, à côté de ceux demain. Le déploiement ne lit que l'empreinte notée par une exécution demain, donc une image de branche ne peut pas partir en production par erreur ; mais un registre de production qui accepte les poussées de n'importe quelle branche est une surface d'attaque. On sépare souvent registre de développement et registre de production, ou l'on signe les images issues demain(cours Construire des images de conteneurs, leçon 9). - Le geste humain est un contrôle. La promotion manuelle en production n'est pas qu'une prudence métier : c'est un point où une seconde personne peut approuver. Les plateformes l'outillent (environments avec required reviewers chez GitHub, déploiements protégés chez GitLab), avec une trace de qui a approuvé quoi.
En production
- Des environnements éphémères. Au lieu d'une seule recette partagée, les plateformes modernes créent un environnement par demande de fusion, détruit à la fusion. Le relecteur teste la fonctionnalité en vrai. Avec notre serveur, ce serait un projet Compose
signalements-<branche>sur un port libre. - Déployer progressivement. Remplacer toutes les instances d'un coup expose tous les utilisateurs au défaut éventuel. Les stratégies progressives (rolling update, blue-green, canary) limitent l'exposition et automatisent le retour arrière sur la base d'indicateurs ; elles font l'objet du cours Stratégies de déploiement.
- Observer après le déploiement. Le pipeline vérifie que l'application démarre ; il ne voit pas que le taux d'erreurs a doublé dix minutes plus tard. Un déploiement n'est terminé que lorsque les indicateurs de production sont restés sains (chapitre Exploiter).
- Garder les artefacts déployables. Le retour arrière suppose que l'artefact précédent existe encore. Une politique de nettoyage du registre trop agressive peut supprimer l'image vers laquelle on voudrait revenir : conservez au minimum les artefacts déployés en production récemment.
Exercices
1. Livraison ou déploiement (niveau 100). Pour chacune des situations suivantes, dites si l'équipe pratique la livraison continue, le déploiement continu, ou aucun des deux. (a) Chaque commit sur main est construit, testé et déployé en recette ; la production est mise à jour chaque mardi, d'une commande, avec le dernier artefact validé. (b) Chaque commit sur main part automatiquement en production après les tests. (c) Le pipeline teste chaque commit ; pour la production, une personne reconstruit l'application sur son poste à partir de l'étiquette Git de la version.
Solution
(a) Livraison continue : main est déployable en permanence et la mise en production est un geste unique, réalisé au rythme choisi par l'équipe. (b) Déploiement continu. (c) Ni l'un ni l'autre : il y a de l'intégration continue, mais l'artefact livré est reconstruit à la main, hors pipeline ; ce n'est pas celui qui a été testé, et la mise en production dépend d'un poste et d'une personne.
2. Rapprocher le pipeline de la production (niveau 100). Le service app du pipeline n'a pas détecté le problème de répertoire temporaire. Notre orchestrateur ne permet pas de passer des options à docker run pour un service. Proposez une modification de executer qui démarre les services avec les mêmes restrictions que la production (racine en lecture seule, tmpfs sur /tmp, aucune capability), et vérifiez qu'avec cette modification, le pipeline n°15 aurait échoué à l'étape fumee.
Solution
Dans la branche service) d'executer, appliquez les restrictions de la production au seul service qui exécute l'artefact. PostgreSQL, lui, a besoin d'écrire dans son répertoire de données et de changer d'utilisateur au démarrage : lui imposer ces options le casserait.
service) # service <nom> <image|@artefact> [VARIABLE=valeur...]
nom=$image; set -- $commande; image=$1; shift
env=(); for variable in "$@"; do env+=(-e "$variable"); done
restrictions=()
if [ "$image" = @artefact ]; then # l'artefact tourne comme en production
image=$(cat "$travail/artefact")
restrictions=(--read-only --cap-drop ALL --security-opt no-new-privileges)
fi
docker run -d --label "ci.execution=$numero" --network "$reseau" \
--network-alias "$nom" "${restrictions[@]}" "${env[@]}" "$image" > /dev/null
echo " ... service $nom démarré"
continue ;;Pour rejouer le commit du pipeline n°15 sans le pousser à nouveau, on peut appeler l'orchestrateur à la main, depuis le dépôt nu, avec une référence de branche (qui ne déploie pas) :
$ cd serveur-ci/signalements.git
$ ../executer 2027bdb96faf8b7a498818f631671f7c57e9b41f refs/heads/exercice
pipeline n°19 : exercice @ 2027bdb
OK format (2 s)
OK lint (3 s)
OK unitaires (5 s)
OK image (3 s) sha256:c7c63794066e0ad2ee59e9b623c1303ffeca9ff152d4917a97ea3e7b6fd6b44b
... service db démarré
OK integration (5 s)
... service app démarré
ÉCHEC fumee (11 s), fin du journal :
http://app:8000/sante ne répond pas après 10 secondes
résultat : echec (journaux dans /home/.../serveur-ci/travaux/19/journaux)
Le défaut est désormais arrêté dès le pipeline, avant la recette. Lancé avec les mêmes options, l'artefact affiche la cause : FileNotFoundError: [Errno 2] No usable temporary directory found. Cette solution duplique toutefois une partie de la configuration de production dans l'orchestrateur, avec le risque qu'elles divergent. Une meilleure approche consiste à démarrer l'artefact, pour le test de fumée, avec le fichier deploiement/compose.yaml lui-même, dans un projet Compose éphémère propre à l'exécution : c'est le principe des environnements éphémères.
3. Un retour arrière impossible (niveau 100). La version 18 de Signalements renomme la colonne description en details, avec une migration exécutée au démarrage de l'application. Elle est déployée en production, puis on constate un défaut. Que se passe-t-il si l'on redéploie la version 17 ? Comment aurait-il fallu découper ce changement ?
Solution
La version 17 démarre, mais toutes ses requêtes qui citent description échouent avec UndefinedColumn : le schéma a été modifié par la version 18, et le retour arrière de l'image ne revient pas sur la migration. Il aurait fallu un changement parallèle : version 18, ajouter details et écrire dans les deux colonnes ; migrer les anciennes lignes ; version 19, lire details ; version 20 ou plus tard, cesser d'écrire description ; enfin supprimer la colonne. À chaque étape, la version précédente fonctionne encore avec le schéma courant, donc le retour arrière reste possible.
Récapitulatif
- Livraison continue :
mainest déployable à tout moment, le pipeline va jusqu'à la recette, la production est un geste. Déploiement continu : ce geste disparaît. - On construit l'artefact une seule fois et on le désigne par son empreinte ; tous les environnements exécutent exactement le même contenu.
- La configuration (adresses, secrets, nom de l'environnement) est hors de l'artefact, fournie par l'environnement ; la version est dans l'artefact.
- Le test de l'artefact et la recette attrapent ce que les tests du code ne voient pas, à condition de ressembler à la production.
- Revenir en arrière, c'est redéployer un artefact précédent : quelques secondes, à condition que le schéma de données reste compatible (changement parallèle).
- Déployer et mettre à disposition sont deux actes distincts ; les drapeaux de fonctionnalité les séparent.
Pour aller plus loin
- Le chapitre 10 de Continuous Delivery (Humble et Farley), sur le déploiement et la mise à disposition, et les stratégies de retour arrière.
- The Twelve-Factor App, en particulier les facteurs Config et Build, release, run.
- L'article de Timothy Fitz sur le déploiement continu chez IMVU, pour mesurer ce que cela exigeait déjà en 2009 en matière de tests et de surveillance.
- La leçon suivante, qui regarde notre pipeline avec les yeux d'un attaquant.
Sources
- Martin Fowler, Continuous Delivery (bliki, 2013)
- Jez Humble, David Farley, Continuous Delivery, chapitre 5 (Only Build Your Binaries Once) et chapitre 10 (Deploying and Releasing Applications)
- Timothy Fitz, Continuous Deployment at IMVU: Doing the impossible fifty times a day (2009)
- The Twelve-Factor App, III. Config et V. Build, release, run
- DORA, Continuous delivery et Deployment automation (capacités techniques)
- Danilo Sato, Parallel Change (martinfowler.com, 2014)
- Docker, Compose file reference : interpolation et --env-file
- OCI Distribution Specification (références par empreinte)