Aller au contenu
Livraison continue et déploiement continu

Livraison continue et déploiement continu

À la fin, vous saurez

  • Distinguer intégration continue, livraison continue et déploiement continu
  • Expliquer pourquoi l'artefact est construit une seule fois et identifié par son empreinte
  • Séparer l'artefact de la configuration propre à chaque environnement
  • Promouvoir un même artefact de la recette à la production, puis revenir à la version précédente
  • Distinguer déployer et mettre à disposition (release)

Prérequis

Testé avec compose 5.5.1 docker 29.8.1 postgresql 18.6 python 3.14.7 registry 3 , vérifié le 1 octobre 2026

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 continueLivraison continueDéploiement continu
PromesseLe code de main est intégré et vérifié en permanencemain est déployable à tout momentChaque changement de main est déployé
Dernière étape automatiqueTestsDéploiement en recette, artefact prêtDéploiement en production
Mise en productionHors sujetDécision humaine, d'un gesteAutomatique

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 :

ÉtapeCe qu'elle vérifieSur quoi
format, lint, unitairesLe code est correct en isolationLes sources
imageL'artefact se construit ; il est publié et identifiéLes sources, une fois
integrationLe code dialogue correctement avec PostgreSQLLes sources et un service
fumeeL'artefact démarre et répondL'image publiée
deployer recetteL'artefact se déploie avec une configuration réelleL'image publiée, configuration de recette
(geste humain) productionLa 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 --wait ne 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.log garde 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 image note l'empreinte renvoyée par le registre dans travaux/<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.
  • deployer ne s'exécute que pour main. Une branche construit et teste son artefact, mais ne le déploie nulle part. C'est l'équivalent de if: 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      recette

L'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 mode 600, 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-livraison est dans le registre, à côté de ceux de main. Le déploiement ne lit que l'empreinte notée par une exécution de main, 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 de main (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 : main est 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.
Voir ma constellation →

Sources