Aller au contenu

Bonnes pratiques CI/CD

Mise à jour :

Imaginez deux usines qui fabriquent le même produit. La première tourne comme une horloge : chaque pièce est identique, les défauts sont détectés en quelques secondes, et quand il y a un problème, on sait exactement où chercher. La seconde est chaotique : chaque lot est légèrement différent, les erreurs passent inaperçues pendant des heures, et quand ça casse, personne ne sait pourquoi.

La différence ? Ce n’est pas les machines — c’est les principes d’organisation.

Pour les pipelines CI/CD, c’est pareil. Certaines fonctionnent pendant des années sans intervention. D’autres s’effondrent au moindre changement. La différence tient rarement aux outils (GitHub Actions, GitLab CI, Jenkins…) — elle tient aux principes appliqués.

Ce guide présente les bonnes pratiques qui distinguent une pipeline robuste d’une pipeline fragile. Ces principes sont universels : ils s’appliquent quel que soit l’outil que vous utilisez.

Pourquoi ces pratiques sont-elles importantes ?

Une pipeline CI/CD n’est pas un script qu’on écrit une fois et qu’on oublie. C’est un système critique qui :

  • Valide chaque modification de code avant qu’elle n’atteigne les utilisateurs
  • Automatise les déploiements — parfois des dizaines par jour
  • Détient des secrets (clés d’API, credentials de production)
  • Impacte la productivité de toute l’équipe quand elle dysfonctionne

Une mauvaise pipeline, c’est :

  • Des heures perdues à attendre des résultats
  • Des bugs qui passent en production parce que « les tests étaient verts »
  • Des déploiements qui échouent le vendredi soir
  • Des développeurs qui contournent la pipeline parce qu’elle est trop lente

Une bonne pipeline, c’est :

  • Un feedback en minutes, pas en heures
  • La certitude que ce qui est testé = ce qui est déployé
  • Des déploiements prévisibles et reproductibles
  • Une équipe qui fait confiance à son outillage

En résumé : Une bonne pipeline est prévisible, rapide et autonome. Elle produit le même résultat à chaque exécution, donne un feedback en minutes, et ne dépend pas d’un humain pour fonctionner.

Build once, deploy many : construire une fois, déployer partout

Le principe : l’artefact (image Docker, binaire, bundle JS…) est construit une seule fois, puis déployé tel quel dans chaque environnement (dev, staging, production).

Analogie : imaginez une usine automobile. Est-ce qu’on reconstruit une voiture différente pour chaque client ? Non. On fabrique la voiture une fois, et on la livre. Si le client veut une couleur différente, on ne refabrique pas le moteur — on change juste la peinture (la configuration).

Le problème du rebuild

Voici un pattern très courant… et très risqué :

# ❌ PROBLÈME : on reconstruit pour chaque environnement
staging:
script:
- npm install # Télécharge les dépendances
- npm run build # Build n°1
- docker build -t app:staging .
- docker push registry/app:staging
production:
script:
- npm install # Re-télécharge (versions peut-être différentes !)
- npm run build # Build n°2 (potentiellement différent !)
- docker build -t app:production .
- docker push registry/app:production

Qu’est-ce qui peut mal tourner ?

Entre le build staging et le build production (quelques jours plus tard) :

  • Une dépendance npm a été mise à jour (même en patch version)
  • L’image de base Docker a changé
  • Un outil de build a évolué
  • Une variable d’environnement système est différente

Résultat : l’artefact en production n’est pas celui que vous avez testé en staging. Et quand ça casse, vous entendez : « Mais ça marchait en staging ! »

La solution : promotion d’artefact

# ✅ SOLUTION : un seul build, promu d'environnement en environnement
build:
script:
- npm install
- npm run build
- docker build -t registry/app:$COMMIT_SHA . # Tag unique par commit
- docker push registry/app:$COMMIT_SHA
staging:
script:
- docker pull registry/app:$COMMIT_SHA # Même artefact
- helm upgrade app ./chart --set image.tag=$COMMIT_SHA -f values-staging.yaml
production:
script:
- docker pull registry/app:$COMMIT_SHA # Toujours le même artefact
- helm upgrade app ./chart --set image.tag=$COMMIT_SHA -f values-production.yaml

Ce qui change entre les environnements : uniquement la configuration (variables d’environnement, secrets, URLs de base de données).

Ce qui ne change jamais : l’artefact lui-même.

Pourquoi c’est important

AspectRebuild par environnementPromotion d’artefact
Reproductibilité❌ Incertaine✅ Garantie
Confiance« Ça marchait en staging… »« C’est le même artefact »
Temps de déploiementLong (rebuild complet)Rapide (juste déployer)
RollbackRebuild l’ancienne version (risqué)Redéployer l’ancien tag (instantané)

Comment l’implémenter

Pour que la promotion fonctionne, votre application doit :

  1. Séparer le code de la configuration

    • const API_URL = "https://prod.example.com" en dur dans le code
    • const API_URL = process.env.API_URL lu au démarrage
  2. Accepter la configuration au runtime

    • Variables d’environnement
    • Fichiers de configuration montés
    • Secrets injectés par le système d’orchestration
  3. Tagger les artefacts de manière unique

    • app:staging, app:production (on ne sait pas quel code c’est)
    • app:abc123def456 (SHA du commit = traçabilité totale)

Immutabilité : ce qui est créé ne change jamais

Le principe : un artefact, une fois créé, ne doit jamais être modifié. Si un changement est nécessaire, on crée un nouvel artefact.

Analogie : pensez aux numéros de version des médicaments. Quand un laboratoire découvre un problème avec le lot ABC123, il ne modifie pas ce lot — il le rappelle et en produit un nouveau (ABC124). L’ancien lot reste identifiable et traçable.

Pourquoi l’immutabilité est critique

Imaginez ce scénario :

  1. Lundi : vous déployez l’image app:latest en production, tout fonctionne
  2. Mardi : un collègue rebuild app:latest avec une correction
  3. Mercredi : un autre service redémarre et pull app:latest
  4. Jeudi : ce service ne fonctionne plus, mais personne ne comprend pourquoi

Le problème : app:latest de lundi ≠ app:latest de mardi. Le tag est le même, mais le contenu a changé. C’est l’absence d’immutabilité.

Ce qui casse l’immutabilité

PratiqueProblème
Tags mutables (latest, stable, main)Le contenu change, le nom reste
Modification d’un artefact après créationImpossible de savoir ce qui tourne vraiment
Scripts qui patchent en productionÉtat différent de ce qui est versionné
Déploiement sans tag explicite« Quelle version est en prod ? » — « Euh… »

Ce qui préserve l’immutabilité

Registre d'images avec tags immutables
abc123 ─────▶ image v1.2.3 (ne change jamais)
def456 ─────▶ image v1.2.4 (nouvel artefact)
ghi789 ─────▶ image v1.2.5 (nouvel artefact)
PratiqueBénéfice
Tags par SHA de commit (app:abc123def)Traçabilité totale : code → artefact
Versioning sémantique avec releases figéesv1.2.3 pointe toujours vers le même contenu
Infrastructure as CodeLa config est versionnée, pas modifiée à la main
Registre avec protection des tagsImpossible d’écraser un tag existant

En pratique

# ❌ Tag mutable — dangereux
docker build -t app:latest .
docker push app:latest
# ✅ Tag immutable — sûr
docker build -t app:$CI_COMMIT_SHA .
docker push app:$CI_COMMIT_SHA
# Pour la lisibilité, on peut ajouter un tag sémantique
# MAIS le SHA reste la référence de vérité
docker tag app:$CI_COMMIT_SHA app:v1.2.3
docker push app:v1.2.3

Idempotence : même input, même output, toujours

Le principe : exécuter la même pipeline sur le même commit doit produire exactement le même résultat, que ce soit la première ou la dixième fois.

Analogie : une recette de cuisine idempotente donnerait toujours le même gâteau avec les mêmes ingrédients, les mêmes quantités, le même four. En réalité, les recettes ne sont pas idempotentes (la qualité des œufs varie, le four chauffe différemment…). Mais pour une pipeline, l’idempotence est atteignable et essentielle.

Même commit (abc123), trois exécutions différentes
Run 1 (lundi) : commit abc123 → artefact xyz789
Run 2 (mercredi) : commit abc123 → artefact xyz789 ✅ identique
Run 3 (vendredi) : commit abc123 → artefact xyz789 ✅ identique

Pourquoi c’est important

Sans idempotence :

  • Debugging impossible : « Ça marchait hier, pourquoi ça casse aujourd’hui ? »
  • Rollback incertain : rebuilder l’ancienne version ne donne pas le même résultat
  • Tests non fiables : le même test passe ou échoue aléatoirement

Ce qui casse l’idempotence

CauseExempleProblème
Dépendances non épingléesnpm install sans lockfileUne dépendance est mise à jour entre deux builds
Timestamps dans l’artefactDate de build dans le binaireL’artefact change à chaque build
Données externesTests qui appellent une vraie APIL’API peut répondre différemment
État global modifiéScript qui modifie une variable globaleLe deuxième run voit un état différent
Ordre non déterministeTests parallèles mal isolésLes tests s’influencent mutuellement

Ce qui préserve l’idempotence

1. Épingler toutes les dépendances

Terminal window
# ❌ Non déterministe — peut changer demain
npm install
pip install requests
# ✅ Déterministe — toujours les mêmes versions
npm ci # Utilise package-lock.json
pip install -r requirements.txt --no-deps # Versions exactes

Les fichiers de lock à committer :

ÉcosystèmeFichier de lock
Node.js (npm)package-lock.json
Node.js (yarn)yarn.lock
Python (pip)requirements.txt avec versions exactes
Python (poetry)poetry.lock
Gogo.sum
RustCargo.lock

2. Éviter les timestamps et données variables

# ❌ La date change à chaque build
RUN echo "Built on $(date)" > /app/version.txt
# ✅ Utiliser des informations déterministes
ARG COMMIT_SHA
RUN echo "Commit: $COMMIT_SHA" > /app/version.txt

3. Isoler les tests

# ❌ Test qui dépend d'un état global
def test_user_count():
assert User.count() == 5 # Et si un autre test a créé des users ?
# ✅ Test qui crée son propre contexte
def test_user_count():
with fresh_database():
create_users(5)
assert User.count() == 5

4. Mocker les dépendances externes

# ❌ Appelle une vraie API (peut changer, être lente, être down)
def test_weather():
response = requests.get("https://api.weather.com/paris")
assert response.status_code == 200
# ✅ Mock l'API
def test_weather(mock_weather_api):
mock_weather_api.return_value = {"temp": 20}
result = get_weather("paris")
assert result["temp"] == 20

Feedback rapide : savoir en minutes si le code est valide

Le principe : un développeur doit savoir en quelques minutes — pas en heures — si son code est valide.

Pourquoi c’est fondamental ?

Imaginez que vous écrivez un email et que le correcteur orthographique ne souligne les fautes qu’une heure après. Vous auriez déjà oublié le contexte, et corriger deviendrait pénible.

C’est pareil pour le code. Plus le feedback est tardif :

  • Plus le développeur a changé de contexte (il travaille sur autre chose)
  • Plus il est difficile de se souvenir de ce qu’on a fait
  • Plus la correction prend du temps

Objectifs de temps réalistes :

ÉtapeObjectifPourquoi cette durée
Lint + format< 1 minVérifications syntaxiques, très rapides
Tests unitaires< 3 minTests isolés, pas de dépendances externes
Build< 5 minCompilation, bundling
Tests d’intégration< 10 minTests avec base de données, services
Tests end-to-end< 15 minTests navigateur, scénarios complets

Si votre pipeline dépasse 30 minutes, les développeurs commencent à la contourner (« je merge, on verra bien ») ou à faire du multi-tâche inefficace.

Comment accélérer votre pipeline

1. Paralléliser les jobs indépendants

Au lieu d’exécuter lint, tests et scan de sécurité l’un après l’autre, exécutez-les en parallèle :

Parallélisation des jobs dans une
pipeline

2. Fail fast — s’arrêter dès qu’un check rapide échoue

Si le lint échoue en 3 secondes, inutile de lancer les tests qui durent 30 secondes :

lint (3s)
├── échec → STOP (on a gagné 30s)
└── succès → tests (30s)
└── build (2min)

3. Utiliser le cache de manière agressive

Télécharger les dépendances à chaque exécution est du gaspillage :

Sans cacheAvec cache
npm install : 3 minnpm install : 10 sec
pip install : 2 minpip install : 5 sec
Téléchargement images Docker : 1 minDéjà en cache : 0 sec

4. Organiser les tests en pyramide

L’idée est d’avoir beaucoup de tests rapides (unitaires) et peu de tests lents (end-to-end) :

Pyramide des tests dans une pipeline
CI/CD

Les tests unitaires détectent 80% des bugs en quelques secondes. Les tests E2E vérifient les scénarios critiques, mais sont réservés aux parcours essentiels.

Séparation build / deploy : deux responsabilités distinctes

Le principe : le build crée un artefact générique. Le deploy l’installe avec une configuration spécifique. Les deux ne doivent pas être mélangés.

Analogie : une usine de téléphones fabrique des appareils identiques. C’est au moment de l’activation que le client choisit sa langue, son opérateur, ses apps. L’usine ne fabrique pas un téléphone différent pour chaque client.

Le problème du couplage

# ❌ COUPLAGE : le build connaît la destination
build-staging:
script:
- API_URL=https://staging.example.com npm run build
- docker build -t app:staging .
- deploy-to-staging
build-production:
script:
- API_URL=https://api.example.com npm run build
- docker build -t app:production .
- deploy-to-production

Problèmes :

  • Duplication de code (le build est répété deux fois)
  • Impossible de promouvoir l’artefact (staging ≠ production)
  • Si on ajoute un environnement (preprod), il faut un nouveau job

La solution : séparation claire

# ✅ SÉPARATION : build générique, deploy configuré
build:
script:
- npm run build # Pas de config d'environnement
- docker build -t app:$COMMIT_SHA .
- docker push registry/app:$COMMIT_SHA
deploy-staging:
needs: [build]
script:
- docker pull registry/app:$COMMIT_SHA
- helm upgrade app ./chart \
--set image.tag=$COMMIT_SHA \
--set apiUrl=https://staging.example.com \
-f values-staging.yaml
deploy-production:
needs: [build]
script:
- docker pull registry/app:$COMMIT_SHA # Même artefact !
- helm upgrade app ./chart \
--set image.tag=$COMMIT_SHA \
--set apiUrl=https://api.example.com \
-f values-production.yaml

Ce que chaque étape doit faire

ÉtapeResponsabilitéCe qu’elle NE fait PAS
BuildCompiler, bundler, créer l’artefactInjecter des URLs, secrets, configs d’env
TestValider que l’artefact fonctionneModifier l’artefact
DeployInstaller l’artefact + injecter la configRecompiler, modifier le code

Comment gérer la configuration

La configuration spécifique à l’environnement doit être injectée au runtime, pas au build :

// ❌ Configuration au build (en dur dans le code)
const API_URL = "https://api.example.com";
// ✅ Configuration au runtime
const API_URL = process.env.API_URL || "http://localhost:3000";
values-staging.yaml
apiUrl: https://staging.example.com
logLevel: debug
replicas: 1
# values-production.yaml
apiUrl: https://api.example.com
logLevel: warn
replicas: 3

Pipeline déclarative : décrire quoi, pas comment

Le principe : votre fichier de pipeline doit décrire ce que vous voulez obtenir (quoi), pas les détails d’implémentation (comment). C’est la plateforme qui gère l’orchestration.

Analogie : quand vous commandez au restaurant, vous dites « un steak frites » — pas « prenez 200g de bœuf, chauffez la poêle à 220°C, faites revenir 3 minutes de chaque côté… ». Le chef sait comment faire.

Le problème de l’impératif

# ❌ IMPÉRATIF : le script gère tout lui-même
steps:
- run: |
if [ "$BRANCH" = "main" ]; then
npm run build
docker build -t app .
docker push registry/app:latest
if [ "$DEPLOY" = "true" ]; then
kubectl config use-context production
kubectl apply -f k8s/
kubectl rollout status deployment/app
fi
elif [ "$BRANCH" = "develop" ]; then
npm run build
docker build -t app .
docker push registry/app:dev
# ... encore du code
fi

Problèmes :

  • Difficile à lire : il faut suivre la logique du script
  • Difficile à maintenir : un changement peut casser autre chose
  • Pas de réutilisation : tout est dans un gros script
  • Pas de parallélisation : la plateforme ne peut pas optimiser

La solution déclarative

# ✅ DÉCLARATIF : intention claire, plateforme orchestre
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- run: npm run build
- uses: docker/build-push-action@v5
with:
push: true
tags: registry/app:${{ github.sha }}
deploy-staging:
needs: build
if: github.ref == 'refs/heads/main'
environment: staging
steps:
- uses: azure/k8s-deploy@v4
with:
manifests: k8s/staging/
images: registry/app:${{ github.sha }}
deploy-production:
needs: deploy-staging
if: github.ref == 'refs/heads/main'
environment: production # Approbation requise
steps:
- uses: azure/k8s-deploy@v4
with:
manifests: k8s/production/
images: registry/app:${{ github.sha }}

Avantages du déclaratif

AspectImpératifDéclaratif
LisibilitéSuivre la logique du scriptStructure visible immédiatement
RéutilisationCopier-collerActions/plugins partagés
MaintenanceModifier des scripts complexesChanger des paramètres
OptimisationManuelle (parallélisation)Automatique par la plateforme
VisualisationPas de grapheGraphe de dépendances généré

Les concepts déclaratifs clés

ConceptSignificationExemple
needs« Ce job dépend de celui-là »needs: [build, test]
if« Exécuter seulement si… »if: github.ref == 'refs/heads/main'
environment« Déployer dans cet environnement »environment: production
matrix« Exécuter pour chaque combinaison »matrix: { node: [18, 20] }
uses« Utiliser cette action réutilisable »uses: actions/checkout@v4

Fail fast, fail loud : échouer vite et clairement

Le principe : quand quelque chose ne va pas, la pipeline doit s’arrêter immédiatement et le signaler clairement.

Pourquoi c’est important ?

Une erreur silencieuse ou tardive est bien pire qu’une erreur bruyante et immédiate :

  • Silencieuse : le bug passe en production, les utilisateurs le découvrent
  • Tardive : vous avez déjà mergé 10 autres commits, impossible de savoir lequel a cassé
  • Bruyante et immédiate : vous corrigez en 5 minutes, personne n’est impacté

Les trois règles de l’échec

1. S’arrêter immédiatement

Ne jamais continuer avec un état invalide. Si une étape échoue, les suivantes n’ont pas de sens.

Terminal window
# ❌ Script qui ignore les erreurs
result=$(command_that_might_fail)
# Continue même si la commande a échoué...
next_command
deploy # Déploie du code potentiellement cassé !
Terminal window
# ✅ Script qui s'arrête au premier problème
set -euo pipefail # Arrêt automatique en cas d'erreur
result=$(command_that_might_fail)
# Si la commande échoue, le script s'arrête ici
next_command # N'est jamais atteint si échec

Explication de set -euo pipefail :

OptionSignification
-eArrêter le script si une commande retourne une erreur
-uArrêter si une variable non définie est utilisée
-o pipefailConsidérer un pipeline comme échoué si n’importe quelle commande échoue

2. Notifier clairement

Une notification d’échec doit être :

Critère❌ Mauvais✅ Bon
Timing30 minutes après l’échecImmédiatement
DestinataireToute l’équipe (spam)L’auteur du commit
Contenu« Pipeline failed »« Test user_login_test failed: expected 200, got 401 »
ActionChercher dans les logsLien direct vers la ligne qui a échoué

3. Faciliter le debugging

Quand une pipeline échoue, le développeur doit pouvoir comprendre pourquoi sans fouiller 500 lignes de logs :

  • Logs structurés : sections claires pour chaque étape
  • Contexte préservé : variables d’environnement, versions utilisées
  • Artefacts de debug : screenshots pour les tests E2E, rapports de couverture
  • Reproduction locale possible : « Pour reproduire : make test-integration »

Observabilité : voir ce qui se passe sans fouiller

Le principe : la pipeline doit exposer son état de santé de manière proactive. Vous ne devriez pas avoir à fouiller les logs pour savoir si tout va bien.

Analogie : le tableau de bord d’une voiture vous montre la vitesse, le niveau d’essence, la température moteur — sans que vous ayez à ouvrir le capot. Une pipeline observable fait pareil : elle vous dit son état en un coup d’œil.

Ce qu’il faut mesurer

MétriqueCe qu’elle révèleSeuil d’alerte typique
Durée totaleRégression de performance> 15 min (selon contexte)
Durée par jobGoulot d’étranglementJob qui double de durée
Taux de succèsSanté globale< 90% sur 24h
Tests flakyTests non fiablesMême test échoue > 2x/semaine aléatoirement
Temps de queueManque de runners> 5 min d’attente
Fréquence de déploiementVélocité de l’équipeBaisse soudaine

Les tests flaky : un cancer silencieux

Un test flaky est un test qui échoue parfois sans raison apparente, puis passe à la relance.

Pourquoi c’est grave :

  • Les développeurs perdent confiance dans la pipeline (« oh, ça a échoué, je relance »)
  • Les vrais problèmes passent inaperçus (noyés dans le bruit)
  • Temps perdu à relancer des pipelines

Comment les détecter :

  • Suivre les tests qui échouent puis passent sur le même commit
  • Identifier les tests avec un taux de succès < 99%
  • Alerter quand un test spécifique échoue plus de X fois par semaine

Que faire :

  1. Marquer le test comme flaky (pour ne pas bloquer les autres)
  2. Créer un ticket prioritaire pour le corriger
  3. Corriger la cause racine (souvent : dépendance au timing, état partagé, ressource externe)

Alertes recommandées

Ne vous noyez pas dans les alertes. Concentrez-vous sur ce qui est actionnable :

ConditionAction requise
Pipeline principale > 20 minInvestiguer la régression de performance
Taux de succès < 85% sur 24hProblème systémique, prioriser la correction
Test X échoue > 3 fois cette semaineMarquer comme flaky, créer ticket
Temps de queue > 10 minAjouter des runners ou optimiser le parallélisme
Déploiement production échoueAlerte immédiate à l’équipe on-call

Dashboard minimum viable

Un bon dashboard de pipeline montre en un coup d’œil :

Exemple de dashboard de
pipeline CI/CD

À retenir

  1. Feedback rapide — Le développeur doit savoir en minutes si son code est valide. Parallélisez, cachez, fail fast.

  2. Fail fast, fail loud — Une erreur silencieuse est pire qu’une erreur bruyante. Arrêtez-vous immédiatement, notifiez clairement.

  3. Build once, deploy many — Construisez l’artefact une fois, déployez-le partout. Ce que vous testez = ce que vous déployez.

  4. Immutabilité — Un artefact créé ne change jamais. Si changement nécessaire, créez un nouvel artefact. Évitez les tags mutables (latest).

  5. Idempotence — Même commit = même résultat. Épinglez les dépendances, isolez les tests, évitez les états globaux.

  6. Séparation build/deploy — Le build crée un artefact générique. Le deploy injecte la configuration. Ne mélangez pas.

  7. Pipeline déclarative — Décrivez le quoi, pas le comment. Laissez la plateforme orchestrer.

  8. Observabilité — Mesurez la durée, le taux de succès, les tests flaky. Alertez sur ce qui est actionnable.

Checklist avant de modifier une pipeline

Avant de merger une modification de pipeline, vérifiez :

  • Build once : L’artefact est construit une seule fois
  • Immutabilité : Les tags sont immutables (SHA, pas latest)
  • Idempotence : Les dépendances sont épinglées (lockfiles committés)
  • Feedback rapide : La pipeline complète en moins de 15 minutes
  • Fail fast : Un échec arrête la pipeline immédiatement
  • Notifications : Les alertes sont actionnables (lien direct, contexte)
  • Séparation : Build et deploy sont des jobs distincts
  • Déclaratif : La logique est dans la structure, pas dans des scripts

Pour aller plus loin