Aller au contenu

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

  1. Ne pas lancer ce qui est inutile : workflow:rules, rules:changes, annulation des pipelines redondantes
  2. Lancer plus tôt : graphe de dépendances avec needs
  3. Lancer plus vite : cache, artefacts, images légères, cache de build, analyses différentielles
  4. Lancer en parallèle : parallel et parallel:matrix
  5. En dernier : un runner plus gros

  6. Un levier à la fois, mesuré avant et après

  7. 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