Performance et ressources d'une usine logicielle
Objectifs pédagogiques
- Distinguer durée ressentie, durée d'exécution, attente et temps machine d'une pipeline
- Identifier le chemin critique et les jobs instables à partir de l'API GitLab
- Relier la performance de la chaîne aux indicateurs DORA
- Appliquer les leviers dans le bon ordre : ne pas lancer, lancer plus tôt, plus vite, en parallèle
- Dimensionner les runners et maîtriser le coût de l'inactivité
- Arbitrer entre temps humain, temps machine et empreinte, et gouverner par un objectif de durée
Mesurer avant d'optimiser
Pourquoi la durée de pipeline compte
- Plus la boucle de retour est longue, plus le développeur change de contexte et plus les MR s'empilent
- La durée de pipeline est une composante directe du délai de mise en production
- Chaque minute de pipeline consomme des runners, de l'énergie et du stockage
- Une pipeline lente finit contournée :
[skip ci], fusion sans attendre le résultat, jobs désactivés - Sans mesure, on optimise au hasard : souvent un job qui n'est pas sur le chemin critique
Quatre mesures à ne pas confondre
| Mesure | Définition | Source dans l'API |
| Durée ressentie | de la création de la pipeline à sa fin | finished_at - created_at |
| Durée d'exécution | union des périodes où au moins un job tourne, hors attente et hors tentatives relancées | duration |
| Attente | temps passé en file avant qu'un runner prenne la pipeline ou le job | queued_duration |
| Temps machine | somme des durées de tous les jobs, relances comprises | somme des duration des jobs |
- Le développeur subit la durée ressentie ; le budget paie le temps machine
- Minutes de calcul GitLab.com : durée du job / 60 × facteur de coût du runner
- Temps machine / durée d'exécution = parallélisme moyen de la pipeline
Le chemin critique
Pipeline par stages : chaque stage attend le plus lent du précédent
install ──▶ lint ──▶ test ──▶ build:image ──▶ scan:image ──▶ deploy
├─▶ sast:semgrep
└─▶ secrets:gitleaks
Pipeline en graphe : chaque job attend seulement ce dont il a besoin
lint test secrets sast
build:image ──▶ scan:image ──▶ deploy
- La plus longue chaîne de jobs dépendants, attentes comprises, fixe la durée ressentie
- Accélérer un job hors du chemin critique réduit le coût, pas la durée
- Dans GitLab : graphe de pipeline, regroupement par Job dependencies
Jobs instables et taux de succès
- Un job instable (flaky) échoue sans changement de code : relances manuelles, confiance perdue
- Une relance sur le chemin critique ajoute toute la durée du job à celle de la pipeline
retry masque le problème s'il couvre les échecs de script : le réserver aux pannes d'infrastructure
test:
retry:
max: 2
when: [runner_system_failure, scheduler_failure, api_failure]
- Mesurer : taux d'échec par job, nombre de relances (
include_retried=true dans l'API) - Un test instable va en quarantaine, avec un ticket et une échéance, pas en
allow_failure permanent
Où trouver les chiffres
- CI/CD analytics (Free) : nombre de pipelines, durée médiane et p95, taux d'échec, filtres par source, branche et période
- Performances par job : P50, P95 et taux d'échec de chaque job (Premium et Ultimate, GitLab.com, disponibilité limitée depuis 18.9)
- API REST : pipelines et jobs, scriptable, exportable vers un tableur ou un rapport
- Prometheus :
gitlab-ci-pipelines-exporter et un tableau de bord Grafana pour suivre la tendance - Une pipeline isolée ne prouve rien : comparer des médianes et des p95 sur plusieurs exécutions
L'API REST en deux appels
API="https://gitlab.com/api/v4/projects/$PROJECT_ID/pipelines/$PIPELINE_ID"
# La pipeline : statut, durée d'exécution, attente, horodatages
curl -sSf -H "PRIVATE-TOKEN: $GITLAB_TOKEN" "$API" \
| jq '{status, duration, queued_duration, created_at, finished_at}'
# Ses jobs, y compris les tentatives relancées
curl -sSf -H "PRIVATE-TOKEN: $GITLAB_TOKEN" "$API/jobs?per_page=100&include_retried=true" \
| jq -r '.[] | [.name, .stage, .status, .queued_duration, .duration] | @tsv'
- Jeton d'accès personnel au scope
read_api, avec une expiration courte - Les horodatages des jobs (
started_at, finished_at) permettent de reconstruire le chemin critique - Le TP fournit un script complet qui produit un rapport en Markdown
Les indicateurs DORA
| Indicateur | Question | Lien avec la pipeline |
| Deployment frequency | à quelle fréquence livre-t-on en production | une pipeline lente pousse à regrouper les changements |
| Lead time for changes | combien de temps entre le merge et la production | la durée de pipeline en est une composante directe |
| Change failure rate | quelle part des déploiements provoque un incident | les tests et les scans gardent la qualité quand on accélère |
| Time to restore service | combien de temps pour rétablir le service | le correctif traverse la même pipeline |
- Vitesse et stabilité se lisent ensemble : accélérer en retirant des contrôles dégrade les deux dernières
- Dans GitLab, les tableaux de bord DORA sont en Ultimate ; en Free, on calcule soi-même fréquence et délai depuis l'API des déploiements
- DORA en suit désormais cinq, avec le deployment rework rate, et parle de failed deployment recovery time
Les leviers d'optimisation
Par où commencer
- Ne pas lancer ce qui est inutile :
workflow:rules, rules:changes, annulation des pipelines redondantes - Lancer plus tôt : graphe de dépendances avec
needs - Lancer plus vite : cache, artefacts, images légères, cache de build, analyses différentielles
- Lancer en parallèle :
parallel et parallel:matrix -
En dernier : un runner plus gros
-
Un levier à la fois, mesuré avant et après
- Les deux premiers gagnent sur la durée et sur le coût ; le parallélisme achète de la durée avec du temps machine
Le graphe de dépendances : needs
lint:
stage: test
needs: [] # démarre dès la création de la pipeline
build:image:
stage: build
needs: [] # le build n'attend pas les tests
scan:image:
stage: scan
needs:
- job: build:image
artifacts: false # besoin d'ordre, pas de fichiers
deploy:staging:
stage: deploy
needs: [lint, test, scan:image]
- L'ordre réel vient de
needs ; les stages ne servent plus qu'à l'affichage - Jusqu'à 50 jobs dans un
needs, y compris des jobs du même stage
needs et artefacts
- Sans
needs ni dependencies, un job télécharge les artefacts de tous les stages précédents - Avec
needs, il ne télécharge que ceux des jobs listés : un job qui comptait sur un artefact implicite casse artifacts: false quand on attend la fin d'un job sans avoir besoin de ses fichiers - Si un job listé dans
needs est exclu par ses règles, la pipeline ne se crée pas
deploy:staging:
needs:
- job: test
optional: true # absent de la pipeline : on ne l'attend pas
- job: scan:image
- Construire le build en parallèle des tests est un pari : un test en échec rend le build inutile
Ne lancer que les pipelines utiles
workflow:
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_PIPELINE_SOURCE == "schedule"
- if: $CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS
when: never
- if: $CI_COMMIT_BRANCH
- Dès qu'un job cible
merge_request_event, un push sur une branche avec MR crée deux pipelines : branche et MR - Ces règles gardent la pipeline de MR quand une MR est ouverte, la pipeline de branche sinon
- Les pipelines planifiées sont acceptées explicitement, même sur une branche qui a une MR ouverte
Ne lancer que les jobs utiles : rules:changes
test:
rules:
- if: $CI_PIPELINE_SOURCE == "schedule"
when: never
- changes:
- src/**/*
- tests/**/*
- package.json
- package-lock.json
- .gitlab-ci.yml
- Pipeline de MR : comparaison avec la branche cible ; pipeline de branche : avec le commit précédent
- Toujours vrai sur une nouvelle branche et sans événement de push (planifiée, manuelle), sauf avec
compare_to - Une MR sans aucune pipeline ne se fusionne pas si la réussite de la pipeline est exigée : garder au moins un job toujours présent, comme la détection de secrets
Annuler les pipelines redondantes
workflow:
auto_cancel:
on_new_commit: interruptible # n'annule que les jobs interruptibles
default:
interruptible: true
deploy:staging:
interruptible: false # jamais interrompu en cours
- Le paramètre du projet doit être actif : Settings > CI/CD > General pipelines > Auto-cancel redundant pipelines
- Trois commits poussés en une minute ne coûtent plus trois pipelines complètes
on_job_failure: all annule le reste de la pipeline au premier échec : plus sobre, mais on perd les résultats des autres jobs
Un cache bien clé
.node:
image: node:24-alpine
cache:
key:
files: [package-lock.json] # nouvelle clé quand le lockfile change
prefix: npm
paths: [.npm/]
policy: pull # les lecteurs ne réécrivent pas le cache
before_script:
- npm ci --cache .npm --prefer-offline --no-audit --no-fund
lint:
extends: .node
cache:
policy: pull-push # un seul job met le cache à jour
- Branches protégées et non protégées ont des caches distincts (suffixes
-protected et -non_protected) : la pipeline de MR d'un développeur n'écrit pas dans le cache de main cache:unprotect: true supprime cette protection : à éviter node_modules/ en artefact (TP1) envoie et stocke des dizaines, voire des centaines de Mo par pipeline : un cache de .npm/ et un npm ci par job coûtent moins
Des artefacts sobres
test:
artifacts:
when: always
expire_in: 1 week
reports:
junit: reports/junit.xml
- Sans
expire_in, un artefact vit 30 jours par défaut, et ceux de la dernière pipeline réussie sont conservés - Une durée adaptée à l'usage : quelques heures pour un passage de relais, plus longtemps pour une preuve d'audit
- Ne publier que ce qui est lu, exclure le reste avec
artifacts:exclude - Avec
FF_USE_FASTZIP, ARTIFACT_COMPRESSION_LEVEL et CACHE_COMPRESSION_LEVEL arbitrent entre taille et vitesse
Paralléliser : parallel et parallel:matrix
test:
parallel:
matrix:
- SUITE: [unit, integration] # test: [unit] et test: [integration]
script:
- npm test -- --testPathPattern=tests/$SUITE
test:sharded:
parallel: 3 # test:sharded 1/3 à 3/3
script:
- npm test -- --shard=$CI_NODE_INDEX/$CI_NODE_TOTAL
- Chaque copie paie le coût fixe d'un job : démarrage du runner, image, clone, dépendances
- La durée baisse, le temps machine monte : rentable seulement si la suite de tests est longue
- Plus de copies que de fichiers de test : un shard vide, et Jest échoue sans
--passWithNoTests
Le build d'image : cache BuildKit dans le registre
buildctl-daemonless.sh build \
--frontend dockerfile.v0 --local context=. --local dockerfile=. \
--import-cache type=registry,ref=$CI_REGISTRY_IMAGE/cache:$CI_COMMIT_REF_SLUG \
--import-cache type=registry,ref=$CI_REGISTRY_IMAGE/cache:$CI_DEFAULT_BRANCH_SLUG \
--export-cache type=registry,ref=$CI_REGISTRY_IMAGE/cache:$CI_COMMIT_REF_SLUG,mode=max,ignore-error=true \
--output type=image,name=$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA,push=true
- Un cache par branche, avec repli sur celui de la branche par défaut au premier build d'une branche
mode=max exporte aussi les étapes intermédiaires d'un multi-stage, au prix de plus de stockage ignore-error=true : un export de cache raté ne fait pas échouer le build - Le cache ne sert que si le Dockerfile copie les dépendances avant le code source
Des images légères, partout
- Image applicative : multi-stage, Alpine ou distroless,
.dockerignore ; elle se pousse, se tire et se scanne plus vite - Images d'outillage : une image par usage, version épinglée, outils préinstallés plutôt qu'installés à chaque job
- Clone :
GIT_DEPTH faible par défaut, historique complet seulement pour les jobs qui le lisent GIT_STRATEGY: none pour un job qui ne travaille que sur une image ou des artefacts - Base de vulnérabilités Trivy en cache (
TRIVY_CACHE_DIR) plutôt que téléchargée à chaque scan
Le bon scan au bon moment
| Déclencheur | Scans | Objectif |
| Merge Request | secrets et SAST différentiels, image construite et scannée | retour en quelques minutes |
| Branche par défaut | scan de l'image, déploiement en staging | valider ce qui part |
| Pipeline planifiée nocturne | secrets sur tout l'historique, SAST complet, DAST actif | couverture totale |
- Sur la MR, seuls les constats introduits par la MR remontent ; le stock est traité la nuit
- Ce qui n'est plus scanné sur la MR doit l'être ailleurs : l'optimisation ne retire aucune couverture
- Commandes et exemples : cours IAST & DAST authentifié
Sérialiser les déploiements : resource_group
deploy:staging:
resource_group: staging
environment:
name: staging
- Deux déploiements du même environnement ne tournent jamais en même temps, même depuis deux pipelines
- Ordre de passage réglable par l'API :
unordered (défaut), oldest_first, newest_first, newest_ready_first - Avec Prevent outdated deployment jobs, un ancien déploiement ne remplace pas un plus récent
- Ce n'est pas un levier de vitesse : c'est ce qui permet d'accélérer sans course entre déploiements
Les runners
Runners hébergés ou auto-hébergés
| Hébergés sur GitLab.com | Auto-hébergés |
| Coût | minutes de calcul : 400 par mois en Free, puis achat | infrastructure, même quand rien ne tourne |
| Tailles Linux | small 2 vCPU et 8 Go (facteur 1), medium 4 vCPU et 16 Go (facteur 2) ; large et plus en Premium | au choix |
| Démarrage | une VM éphémère par job | pods ou VM à la demande, ou capacité en attente |
| Cache | distribué, géré par GitLab | à fournir : S3, MinIO, volume partagé |
| Réseau | Internet uniquement | accès aux environnements internes |
- Choix par job avec
tags (saas-linux-medium-amd64) : un runner deux fois plus cher doit rendre le job deux fois plus rapide pour rester neutre en minutes - Seuls les jobs limités par le CPU (build d'image, compilation) gagnent à grossir : mesurer l'usage réel avant de choisir
Requêtes et limites sur Kubernetes
[runners.kubernetes]
cpu_request = "500m"
cpu_limit = "2"
memory_request = "1Gi"
memory_limit = "2Gi"
cpu_limit_overwrite_max_allowed = "4"
memory_limit_overwrite_max_allowed = "4Gi"
build:image:
variables:
KUBERNETES_CPU_LIMIT: "4" # plafonné par *_overwrite_max_allowed
KUBERNETES_MEMORY_LIMIT: "4Gi"
- La requête réserve la capacité et décide du placement ; la limite borne la consommation
- Requête trop haute : nœuds payés mais vides ; limite mémoire trop basse : job tué (
OOMKilled) - Le conteneur helper et les services ont leurs propres réglages (
helper_*, service_*)
Mise à l'échelle et coût de l'inactivité
[[runners.autoscaler.policy]]
idle_count = 0
idle_time = "0s"
periods = ["* * * * *"]
[[runners.autoscaler.policy]] # la dernière politique qui correspond s'applique
idle_count = 2
idle_time = "30m0s"
periods = ["* 8-18 * * mon-fri"]
timezone = "Europe/Paris"
- Capacité en attente aux heures ouvrées, zéro la nuit et le week-end
- Sur Kubernetes : l'autoscaler du cluster (ou Karpenter) ajoute et retire des nœuds selon les pods de jobs en attente
- Capacité chaude : démarrage immédiat mais payée à vide ; mise à zéro : sobre mais attente au premier job
- L'attente (
queued_duration) mesure directement ce compromis
Sobriété et FinOps
L'empreinte d'une usine logicielle
- Calcul : minutes de runners, dont une part rejouée pour rien (pipelines en double, scans complets à chaque commit, relances)
- Stockage : artefacts, caches, images et couches de cache dans le registre
- Réseau : images et dépendances téléchargées à chaque job
- Leviers : expiration des artefacts, règles de nettoyage du registre (cleanup policies), cycle de vie du stockage objet du cache, images épinglées et réutilisées
- Ce qui n'est pas lancé ne consomme rien : les règles sont le premier levier de sobriété
Arbitrer : temps humain contre temps machine
- Exemple : 10 développeurs, 6 pipelines de MR par jour chacun, 4 minutes d'attente gagnées par pipeline
- 240 minutes de temps humain par jour
- si le parallélisme coûte 3 minutes de calcul de plus par pipeline : 180 minutes de calcul par jour
- Une minute d'ingénieur coûte plus qu'une minute de runner, mais les minutes ne sont pas gratuites : quota, facture, énergie
- Supprimer le travail inutile gagne sur les deux tableaux ; paralléliser déplace le coût
- Chaque arbitrage s'écrit avec ses chiffres, dans un ADR ou un rapport
Gouverner : un objectif de durée
- SLO : « 95 % des pipelines de MR réussies se terminent en moins de 10 minutes, sur 30 jours glissants »
- Indicateur : durée ressentie, mesurée par l'API ou par CI/CD analytics (p95)
- Budget d'erreur épuisé : l'optimisation de la pipeline passe avant l'ajout de nouveaux jobs
- Budget de minutes de calcul par projet, revu chaque mois
- Le
.gitlab-ci.yml a un propriétaire (CODEOWNERS) et chaque nouveau job justifie sa durée et son coût
Anti-patterns à proscrire
- Optimiser sans mesure de départ, ou sur une seule exécution
retry: 2 sur tous les jobs pour masquer des tests instables node_modules/ en artefact sans expiration, et un cache partagé par toutes les branches cache:unprotect: true pour gagner quelques secondes sur les MR - Paralléliser une suite de tests de 20 secondes en 10 copies
- Lancer tous les scans complets sur chaque MR, puis les désactiver parce qu'ils sont trop lents
- Un plus gros runner comme premier réflexe
À retenir
- On mesure d'abord : durée ressentie, durée d'exécution, attente, temps machine, chemin critique
- Ne pas lancer, lancer plus tôt, plus vite, en parallèle, et seulement ensuite un plus gros runner
needs, rules et l'annulation des pipelines redondantes gagnent sur la durée et sur le coût - Cache et artefacts bien employés, images légères et cache BuildKit accélèrent sans rien retirer
- Scans différentiels sur les MR, complets la nuit : rapidité et couverture
- Un objectif de durée et un budget rendent la performance de la chaîne pilotable
Pour aller plus loin