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:productionQu’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.yamlCe 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
| Aspect | Rebuild par environnement | Promotion d’artefact |
|---|---|---|
| Reproductibilité | ❌ Incertaine | ✅ Garantie |
| Confiance | « Ça marchait en staging… » | « C’est le même artefact » |
| Temps de déploiement | Long (rebuild complet) | Rapide (juste déployer) |
| Rollback | Rebuild l’ancienne version (risqué) | Redéployer l’ancien tag (instantané) |
Comment l’implémenter
Pour que la promotion fonctionne, votre application doit :
-
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_URLlu au démarrage
- ❌
-
Accepter la configuration au runtime
- Variables d’environnement
- Fichiers de configuration montés
- Secrets injectés par le système d’orchestration
-
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 :
- Lundi : vous déployez l’image
app:latesten production, tout fonctionne - Mardi : un collègue rebuild
app:latestavec une correction - Mercredi : un autre service redémarre et pull
app:latest - 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é
| Pratique | Problème |
|---|---|
Tags mutables (latest, stable, main) | Le contenu change, le nom reste |
| Modification d’un artefact après création | Impossible 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)| Pratique | Bénéfice |
|---|---|
Tags par SHA de commit (app:abc123def) | Traçabilité totale : code → artefact |
| Versioning sémantique avec releases figées | v1.2.3 pointe toujours vers le même contenu |
| Infrastructure as Code | La config est versionnée, pas modifiée à la main |
| Registre avec protection des tags | Impossible d’écraser un tag existant |
En pratique
# ❌ Tag mutable — dangereuxdocker build -t app:latest .docker push app:latest
# ✅ Tag immutable — sûrdocker 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.3docker push app:v1.2.3Idempotence : 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 xyz789Run 2 (mercredi) : commit abc123 → artefact xyz789 ✅ identiqueRun 3 (vendredi) : commit abc123 → artefact xyz789 ✅ identiquePourquoi 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
| Cause | Exemple | Problème |
|---|---|---|
| Dépendances non épinglées | npm install sans lockfile | Une dépendance est mise à jour entre deux builds |
| Timestamps dans l’artefact | Date de build dans le binaire | L’artefact change à chaque build |
| Données externes | Tests qui appellent une vraie API | L’API peut répondre différemment |
| État global modifié | Script qui modifie une variable globale | Le deuxième run voit un état différent |
| Ordre non déterministe | Tests parallèles mal isolés | Les tests s’influencent mutuellement |
Ce qui préserve l’idempotence
1. Épingler toutes les dépendances
# ❌ Non déterministe — peut changer demainnpm installpip install requests
# ✅ Déterministe — toujours les mêmes versionsnpm ci # Utilise package-lock.jsonpip install -r requirements.txt --no-deps # Versions exactesLes fichiers de lock à committer :
| Écosystème | Fichier de lock |
|---|---|
| Node.js (npm) | package-lock.json |
| Node.js (yarn) | yarn.lock |
| Python (pip) | requirements.txt avec versions exactes |
| Python (poetry) | poetry.lock |
| Go | go.sum |
| Rust | Cargo.lock |
2. Éviter les timestamps et données variables
# ❌ La date change à chaque buildRUN echo "Built on $(date)" > /app/version.txt
# ✅ Utiliser des informations déterministesARG COMMIT_SHARUN echo "Commit: $COMMIT_SHA" > /app/version.txt3. Isoler les tests
# ❌ Test qui dépend d'un état globaldef test_user_count(): assert User.count() == 5 # Et si un autre test a créé des users ?
# ✅ Test qui crée son propre contextedef test_user_count(): with fresh_database(): create_users(5) assert User.count() == 54. 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'APIdef test_weather(mock_weather_api): mock_weather_api.return_value = {"temp": 20} result = get_weather("paris") assert result["temp"] == 20Feedback 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 :
| Étape | Objectif | Pourquoi cette durée |
|---|---|---|
| Lint + format | < 1 min | Vérifications syntaxiques, très rapides |
| Tests unitaires | < 3 min | Tests isolés, pas de dépendances externes |
| Build | < 5 min | Compilation, bundling |
| Tests d’intégration | < 10 min | Tests avec base de données, services |
| Tests end-to-end | < 15 min | Tests 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 :
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 cache | Avec cache |
|---|---|
npm install : 3 min | npm install : 10 sec |
pip install : 2 min | pip install : 5 sec |
| Téléchargement images Docker : 1 min | Dé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) :
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-productionProblè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.yamlCe que chaque étape doit faire
| Étape | Responsabilité | Ce qu’elle NE fait PAS |
|---|---|---|
| Build | Compiler, bundler, créer l’artefact | Injecter des URLs, secrets, configs d’env |
| Test | Valider que l’artefact fonctionne | Modifier l’artefact |
| Deploy | Installer l’artefact + injecter la config | Recompiler, 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 runtimeconst API_URL = process.env.API_URL || "http://localhost:3000";apiUrl: https://staging.example.comlogLevel: debugreplicas: 1
# values-production.yamlapiUrl: https://api.example.comlogLevel: warnreplicas: 3Pipeline 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 fiProblè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
| Aspect | Impératif | Déclaratif |
|---|---|---|
| Lisibilité | Suivre la logique du script | Structure visible immédiatement |
| Réutilisation | Copier-coller | Actions/plugins partagés |
| Maintenance | Modifier des scripts complexes | Changer des paramètres |
| Optimisation | Manuelle (parallélisation) | Automatique par la plateforme |
| Visualisation | Pas de graphe | Graphe de dépendances généré |
Les concepts déclaratifs clés
| Concept | Signification | Exemple |
|---|---|---|
| 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.
# ❌ Script qui ignore les erreursresult=$(command_that_might_fail)# Continue même si la commande a échoué...next_commanddeploy # Déploie du code potentiellement cassé !# ✅ Script qui s'arrête au premier problèmeset -euo pipefail # Arrêt automatique en cas d'erreur
result=$(command_that_might_fail)# Si la commande échoue, le script s'arrête icinext_command # N'est jamais atteint si échecExplication de set -euo pipefail :
| Option | Signification |
|---|---|
-e | Arrêter le script si une commande retourne une erreur |
-u | Arrêter si une variable non définie est utilisée |
-o pipefail | Considérer un pipeline comme échoué si n’importe quelle commande échoue |
2. Notifier clairement
Une notification d’échec doit être :
| Critère | ❌ Mauvais | ✅ Bon |
|---|---|---|
| Timing | 30 minutes après l’échec | Immédiatement |
| Destinataire | Toute l’équipe (spam) | L’auteur du commit |
| Contenu | « Pipeline failed » | « Test user_login_test failed: expected 200, got 401 » |
| Action | Chercher dans les logs | Lien 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étrique | Ce qu’elle révèle | Seuil d’alerte typique |
|---|---|---|
| Durée totale | Régression de performance | > 15 min (selon contexte) |
| Durée par job | Goulot d’étranglement | Job qui double de durée |
| Taux de succès | Santé globale | < 90% sur 24h |
| Tests flaky | Tests non fiables | Même test échoue > 2x/semaine aléatoirement |
| Temps de queue | Manque de runners | > 5 min d’attente |
| Fréquence de déploiement | Vélocité de l’équipe | Baisse 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 :
- Marquer le test comme flaky (pour ne pas bloquer les autres)
- Créer un ticket prioritaire pour le corriger
- 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 :
| Condition | Action requise |
|---|---|
| Pipeline principale > 20 min | Investiguer la régression de performance |
| Taux de succès < 85% sur 24h | Problème systémique, prioriser la correction |
| Test X échoue > 3 fois cette semaine | Marquer comme flaky, créer ticket |
| Temps de queue > 10 min | Ajouter des runners ou optimiser le parallélisme |
| Déploiement production échoue | Alerte immédiate à l’équipe on-call |
Dashboard minimum viable
Un bon dashboard de pipeline montre en un coup d’œil :
À retenir
-
Feedback rapide — Le développeur doit savoir en minutes si son code est valide. Parallélisez, cachez, fail fast.
-
Fail fast, fail loud — Une erreur silencieuse est pire qu’une erreur bruyante. Arrêtez-vous immédiatement, notifiez clairement.
-
Build once, deploy many — Construisez l’artefact une fois, déployez-le partout. Ce que vous testez = ce que vous déployez.
-
Immutabilité — Un artefact créé ne change jamais. Si changement nécessaire, créez un nouvel artefact. Évitez les tags mutables (
latest). -
Idempotence — Même commit = même résultat. Épinglez les dépendances, isolez les tests, évitez les états globaux.
-
Séparation build/deploy — Le build crée un artefact générique. Le deploy injecte la configuration. Ne mélangez pas.
-
Pipeline déclarative — Décrivez le quoi, pas le comment. Laissez la plateforme orchestrer.
-
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
- Qu’est-ce qu’une pipeline CI/CD ? — Les fondamentaux
- Les formes de pipelines — Choisir son architecture selon le contexte
- Anti-patterns CI/CD — Les erreurs classiques à éviter
- Sécuriser une pipeline CI/CD — Modèle de sécurisation approfondi