Mesurer et optimiser la pipeline GitLab CI de SecurePay¶
Objectifs pédagogiques¶
À la fin de ce TP, vous devez être capables de :
- mesurer une pipeline avec l'API GitLab : durée ressentie, attente, temps machine, chemin critique
- appliquer les leviers d'optimisation un par un et chiffrer l'effet de chacun
- éviter les pièges : cache empoisonné, parallélisme trop coûteux,
needset artefacts, règles qui excluent un job attendu - dimensionner un runner à partir de mesures, et non par réflexe
- fixer un objectif de durée de pipeline et le justifier dans un rapport
Prérequis¶
- Le projet SecurePay sur GitLab.com : votre fork des TP précédents, ou un import du dépôt securepay-api (New project > Import project > Repository by URL)
- Des runners : les runners hébergés de GitLab.com (compte validé), ou un runner de projet Docker ou Kubernetes
curl,jq(1.6 ou plus récent) etbashsur votre poste (Linux, macOS ou WSL)- Les bases vues en M1 : stages, cache et artefacts (TP 1), build BuildKit (TP Docker)
Budget de minutes
Un namespace GitLab.com Free dispose de 400 minutes de calcul par mois sur les runners hébergés. La pipeline de départ en consomme une dizaine à chaque exécution : comptez une quinzaine de pipelines pour tout le TP. Un runner de projet ne consomme aucune minute.
Ordres de grandeur
Les durées citées dans ce TP sont des ordres de grandeur : seules vos mesures font foi.
Contexte¶
La pipeline de SecurePay a grossi au fil des TP : lint, tests, build d'image, trois scanners, déploiement. Elle fonctionne, mais les développeurs attendent près de dix minutes à chaque push et le quota de minutes fond. Vous êtes chargés de la rendre rapide et sobre, sans retirer aucun contrôle, et de le prouver par des mesures.
Étape 0 : Préparer le projet¶
-
Créez une branche
perf/pipelineet ouvrez une Merge Request en brouillon versmain: toutes les mesures se font sur cette MR. -
Ajoutez un linter à SecurePay, qui n'en a pas :
// eslint.config.mjs import js from "@eslint/js"; import globals from "globals"; export default [ { ignores: ["coverage/", "reports/", ".npm/"] }, js.configs.recommended, { languageOptions: { globals: { ...globals.node, ...globals.jest } } }, ];npm run lintsignale deux constantes inutilisées danstests/unit/payment.test.js: utilisez-les dans une assertion ou supprimez-les. -
Remplacez
.gitlab-ci.ymlpar la pipeline de départ ci-dessous, volontairement naïve. Gardez pour l'instant leDockerfiled'origine. -
Créez un jeton d'accès personnel (Edit profile > Access tokens) avec le seul scope
read_apiet une expiration à quelques jours. Notez l'identifiant numérique du projet (Settings > General). -
Avant toute mesure, relevez dans cette pipeline au moins six défauts de performance ou de sobriété. Vous vérifierez vos hypothèses au fil du TP.
stages:
- install
- lint
- test
- build
- scan
- deploy
default:
image: node:24-alpine
variables:
GIT_DEPTH: "0"
install:
stage: install
script:
- npm install
artifacts:
paths:
- node_modules/
lint:
stage: lint
script:
- npm run lint
test:
stage: test
script:
- npm test -- --coverage
artifacts:
when: always
paths:
- reports/
- coverage/
reports:
junit: reports/junit.xml
build:image:
stage: build
image:
name: moby/buildkit:v0.34.2-rootless
entrypoint: [""]
variables:
BUILDKITD_FLAGS: --oci-worker-no-process-sandbox
before_script:
- mkdir -p ~/.docker
- echo "{\"auths\":{\"$CI_REGISTRY\":{\"auth\":\"$(printf '%s:%s' "$CI_REGISTRY_USER" "$CI_REGISTRY_PASSWORD" | base64 | tr -d '\n')\"}}}" > ~/.docker/config.json
script:
- |
buildctl-daemonless.sh build \
--frontend dockerfile.v0 \
--local context=. \
--local dockerfile=. \
--output type=image,name=$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA,push=true
secrets:gitleaks:
stage: scan
image:
name: ghcr.io/gitleaks/gitleaks:v8.30.1
entrypoint: [""]
allow_failure: true # TP : on mesure la durée, pas le verdict
script:
- gitleaks git --report-format json --report-path gitleaks.json .
sast:semgrep:
stage: scan
image: semgrep/semgrep:1.183.0
allow_failure: true # TP : on mesure la durée, pas le verdict
script:
- semgrep scan --config p/javascript --metrics=off --error --json --output semgrep.json
scan:image:
stage: scan
image:
name: aquasec/trivy:0.76.1
entrypoint: [""]
allow_failure: true # TP : on mesure la durée, pas le verdict
variables:
TRIVY_USERNAME: $CI_REGISTRY_USER
TRIVY_PASSWORD: $CI_REGISTRY_PASSWORD
script:
- trivy image --exit-code 1 --severity CRITICAL $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
deploy:staging:
stage: deploy
environment:
name: staging
script:
- echo "Déploiement simulé de $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA"
- sleep 15
Pourquoi allow_failure sur les scanners ?
SecurePay contient des failles connues (secret dans l'historique, eval(), image vulnérable) traitées dans les TP précédents. Ici, on mesure la durée, pas le verdict : les scanners ne bloquent pas la pipeline de départ. Ils redeviennent bloquants à l'étape 8.
Étape 1 : Mesurer la situation de départ¶
Travail demandé¶
Ajoutez le script de mesure scripts/ci-metrics.sh et rendez-le exécutable (chmod +x). Il lit la pipeline et ses jobs, relances comprises, et produit un rapport en Markdown.
#!/usr/bin/env bash
# Mesure une pipeline GitLab CI à partir de l'API REST
# Usage : ./ci-metrics.sh <id_projet> <id_pipeline>
# GITLAB_TOKEN jeton d'accès personnel, scope read_api (inutile avec OFFLINE=1)
# GITLAB_URL https://gitlab.com par défaut
# COST_FACTOR facteur de coût du runner (1 pour saas-linux-small-amd64)
# OFFLINE=1 relit les réponses déjà enregistrées dans metrics/
set -euo pipefail
PROJECT="${1:?usage: $0 <id_projet> <id_pipeline>}"
PIPELINE="${2:?usage: $0 <id_projet> <id_pipeline>}"
GITLAB_URL="${GITLAB_URL:-https://gitlab.com}"
COST_FACTOR="${COST_FACTOR:-1}"
mkdir -p metrics
P="metrics/pipeline-$PIPELINE.json"
J="metrics/jobs-$PIPELINE.json"
if [ -z "${OFFLINE:-}" ]; then
: "${GITLAB_TOKEN:?définissez GITLAB_TOKEN (scope read_api)}"
API="$GITLAB_URL/api/v4/projects/$PROJECT/pipelines/$PIPELINE"
curl -sSf --header "PRIVATE-TOKEN: $GITLAB_TOKEN" "$API" -o "$P"
# include_retried : les tentatives relancées comptent dans le temps machine
curl -sSf --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
"$API/jobs?per_page=100&include_retried=true" -o "$J"
fi
jq -r --slurpfile jobs "$J" --argjson cf "$COST_FACTOR" '
def ts: if . == null then null else sub("\\.[0-9]+"; "") | fromdateiso8601 end;
def hms: if . == null then "-" else round as $s
| if $s < 60 then "\($s) s"
else "\($s / 60 | floor) min \($s % 60 | tostring | if length < 2 then "0" + . else . end) s" end end;
def jobname: .name + (if .retried then " (tentative #\(.id), relancée)" else "" end);
. as $p
| ($p.created_at | ts) as $t0
| ($jobs[0] | group_by(.name) | map(max_by(.id) | .id)) as $latest
| [ $jobs[0][]
| . + { retried: (.id as $id | $latest | map(select(. == $id)) | length == 0),
s: (.started_at | ts), f: (.finished_at | ts) } ] as $all
| [ $all[] | select(.s != null and .f != null) ] as $ran
| ([ $ran[].duration // 0 ] | add // 0) as $machine
# Chemin critique observé : on remonte depuis le job terminé en dernier,
# en prenant à chaque fois le job terminé juste avant son démarrage
| def pred($j): [ $ran[] | select(.s < $j.s and .f <= $j.s + 1) ] | max_by(.f);
def chain($j): if $j == null then [] else chain(pred($j)) + [$j] end;
chain($ran | max_by(.f)) as $path
| "## Pipeline \($p.id) : \($p.status), source \($p.source), ref \($p.ref)",
"",
"| Indicateur | Valeur |",
"| :--- | ---: |",
"| Durée ressentie (création → fin) | \(($p.finished_at | ts) - $t0 | hms) |",
"| Durée GitLab (duration, hors attente) | \($p.duration | hms) |",
"| Attente avant démarrage (queued_duration) | \($p.queued_duration | hms) |",
"| Attente cumulée des jobs | \([ $ran[].queued_duration // 0 ] | add // 0 | hms) |",
"| Temps machine (somme des jobs, relances comprises) | \($machine | hms) |",
"| Parallélisme moyen (temps machine / durée GitLab) | \(if ($p.duration // 0) > 0 then ($machine / $p.duration * 10 | round / 10) else "-" end) |",
"| Minutes de calcul estimées (facteur \($cf)) | \($machine / 60 * $cf * 10 | round / 10) |",
"| Jobs exécutés / relancés | \($ran | length) / \([ $all[] | select(.retried) ] | length) |",
"| Artefacts produits, hors journaux | \([ $all[] | .artifacts[]? | select(.file_type != "trace") | .size ] | add // 0 | . / 1048576 * 10 | round / 10) Mo |",
"",
"### Jobs, par ordre de démarrage",
"",
"| Job | Stage | Statut | Attente | Durée | Début (T+) | Fin (T+) |",
"| :--- | :--- | :--- | ---: | ---: | ---: | ---: |",
( $ran | sort_by(.s)[]
| "| \(jobname) | \(.stage) | \(.status) | \(.queued_duration | hms) | \(.duration | hms) | \(.s - $t0 | hms) | \(.f - $t0 | hms) |" ),
"",
"### Chemin critique observé",
"",
"| Job | Attente avant le job | Durée |",
"| :--- | ---: | ---: |",
( range(0; $path | length) as $i | $path[$i]
| "| \(jobname) | \(.s - (if $i == 0 then $t0 else $path[$i - 1].f end) | hms) | \(.duration | hms) |" ),
"",
"Jobs les plus longs : \($ran | sort_by(-(.duration // 0)) | .[0:3] | map("\(.name) (\(.duration | hms))") | join(", "))"
' "$P"
-
Modifiez une ligne de
src/(un message de log suffit), poussez, attendez la fin de la pipeline, puis mesurez-la : -
Recommencez une seconde fois : une mesure isolée ne prouve rien. Retenez pour chaque indicateur la moins favorable des deux, et notez l'écart.
-
Reportez les valeurs dans le tableau de suivi, que vous compléterez à chaque étape :
| Étape | Durée ressentie | Attente cumulée | Temps machine | Minutes estimées | Artefacts | Chemin critique |
|---|---|---|---|---|---|---|
| 0. Départ | ||||||
| 2. Pipelines utiles | ||||||
3. Graphe needs | ||||||
| 4. Cache et artefacts | ||||||
5. rules:changes | ||||||
| 6. Tests parallélisés | ||||||
| 7. Build d'image | ||||||
| 8. Scans différentiels | ||||||
| 9. Runner |
Lire le rapport
Le temps machine est ce que facture GitLab.com, multiplié par le facteur de coût du runner (COST_FACTOR). Le chemin critique est reconstruit à partir des horodatages : en partant du job terminé en dernier, le script remonte à chaque fois vers le job terminé juste avant son démarrage. Comparez-le avec la vue Job dependencies du graphe de pipeline.
Questions¶
- Quels jobs sont sur le chemin critique ? Quelle part de la durée ressentie représente l'attente ?
- Quel est le rapport entre temps machine et durée GitLab ? Qu'en déduisez-vous sur le parallélisme de la pipeline ?
- Quel job produit le plus d'artefacts, et quels jobs les téléchargent sans en avoir besoin ?
Étape 2 : Ne lancer que les pipelines utiles¶
Travail demandé¶
-
Ajoutez la section
workflowen tête du fichier, etinterruptible: truedans la sectiondefaultexistante : -
Sur
deploy:staging, ajoutezinterruptible: false: un déploiement commencé ne doit jamais être coupé. -
Vérifiez que Settings > CI/CD > General pipelines > Auto-cancel redundant pipelines est coché.
-
Poussez deux commits à moins de trente secondes d'intervalle.
Ce que vous devez observer¶
- Les pipelines sont désormais des pipelines de MR (
merge_request_event), une seule par push - La première pipeline passe en
canceleddès que la seconde démarre ; mesurez-la : les minutes déjà consommées sont perdues, les suivantes sont économisées
Étape 3 : Le graphe de dépendances¶
À ce stade, install transmet encore node_modules/ par artefact.
Travail demandé¶
-
Déclarez les dépendances réelles de chaque job. Ajoutez
needs: []àbuild:image(il n'utilise pasnode_modules/),secrets:gitleaksetsast:semgrep, puis : -
Mesurez, et comparez le chemin critique avec celui de l'étape 1.
-
Provoquez le piège : passez
lintàneeds: []et poussez. Le job échoue aveceslint: not found: sansinstalldans sesneeds, il ne reçoit plus l'artefactnode_modules/. Remettezneeds: [install].
Ce que vous devez observer¶
- Le chemin critique se réduit à
build:image,scan:imagepuisdeploy:staging build:imagene télécharge plusnode_modules/, qui ne gonfle plus le contexte de build- La durée ressentie baisse nettement ; le temps machine baisse peu : on attend moins, on ne travaille pas moins
Étape 4 : Cache et artefacts¶
Travail demandé¶
-
Supprimez le job
installet son stage. Chaque job Node.js installe ses dépendances depuis un cache clé sur le lockfile :.node: image: node:24-alpine cache: key: files: - package-lock.json prefix: npm paths: - .npm/ policy: pull before_script: - npm ci --cache .npm --prefer-offline --no-audit --no-fund lint: extends: .node needs: [] cache: policy: pull-push # seul job à mettre à jour le cache script: - npm run lint test: extends: .node needs: [] script: - npm test artifacts: when: always expire_in: 1 week reports: junit: reports/junit.xml -
Déplacez
GIT_DEPTH: "0"de la sectionvariablesglobale vers le seul jobsecrets:gitleaks, qui lit tout l'historique : les autres jobs se contentent de la profondeur par défaut du projet (20). -
Lancez deux pipelines : la première remplit le cache, la seconde l'utilise.
Ce que vous devez observer¶
- Dans le journal de
test, la ligneChecking cache for npm-...-non_protectedpuis l'extraction du cache ;npm ciest plus rapide au second passage - Les artefacts produits passent de plusieurs Mo (l'archive de
node_modules/) à quelques Ko - Le suffixe
-non_protected: le cache d'une pipeline de MR lancée par un développeur est séparé de celui demain
Empoisonnement de cache
Le cache est restauré sans vérification. Si une branche quelconque peut écrire dans le cache lu par main, elle peut y glisser une dépendance modifiée. GitLab sépare les caches des branches protégées et non protégées : ne désactivez jamais cette séparation (cache:unprotect: true), et limitez l'écriture à un seul job.
Étape 5 : Ne lancer que les jobs utiles¶
Travail demandé¶
-
En haut du fichier, avant les jobs (une ancre YAML doit précéder son usage), déclarez les fichiers qui justifient chaque job :
.code-paths: &code-paths - src/**/* - tests/**/* - package.json - package-lock.json - .gitlab-ci.yml .image-paths: &image-paths - src/**/* - package.json - package-lock.json - Dockerfile - .dockerignore - .gitlab-ci.yml .on-code-changes: rules: - if: $CI_PIPELINE_SOURCE == "schedule" when: never - changes: *code-pathsCréez de même
.on-image-changessur*image-paths.lintettestutilisentextends: [.node, .on-code-changes];build:imageetscan:imageutilisentextends: .on-image-changes. -
Le déploiement ne se joue que sur
main, et seulement si l'image a changé. Un changement du seulDockerfilen'ajoute nilintnitest: rendez ces dépendances optionnelles. -
Poussez une modification du seul
README.md, puis du seulDockerfile, et notez les jobs créés.
Ce que vous devez observer¶
- Une modification du seul
README.mdne lance plus que les deux scanners, qui n'ont pas encore de règles. Si tous les jobs avaient une règlechanges, aucune pipeline ne serait créée et une MR qui exige une pipeline réussie resterait bloquée : à l'étape 8, la détection de secrets reste sans règlechanges - Sans
optional: true, la création de la pipeline surmainéchoue avec'deploy:staging' job needs 'test' job, but it was not added to the pipeline rules:changesest toujours vrai sur le premier push d'une nouvelle branche et dans une pipeline lancée à la main : c'est attendu
Étape 6 : Paralléliser les tests¶
Travail demandé¶
-
Découpez les tests par suite avec une matrice :
-
Essayez ensuite une répartition en trois parts :
parallel: 3etnpm test -- --shard=$CI_NODE_INDEX/$CI_NODE_TOTAL. -
Comparez durée ressentie et temps machine avec l'étape 5, puis décidez quelle configuration garder.
Ce que vous devez observer¶
test 3/3échoue avecNo tests found, exiting with code 1: SecurePay n'a que deux fichiers de test, le troisième shard est vide (--passWithNoTestsle ferait passer, sans rien apporter)- La durée ressentie ne bouge guère :
testn'est pas sur le chemin critique (vérifiez-le dans votre rapport) - Le temps machine augmente : chaque copie paie le démarrage du runner, l'image et
npm ci, pour quelques secondes de tests
Un levier mesuré peut être refusé
Ici, la bonne décision est de revenir à un seul job test et de l'écrire dans le rapport, chiffres à l'appui. La matrice deviendra utile quand les tests d'intégration dureront plusieurs minutes.
Étape 7 : Accélérer le build et le scan de l'image¶
Travail demandé¶
-
Remplacez le
Dockerfiled'origine (imagenode:20complète, plus de 1 Go une fois décompressée) par la version multi-stage sur Alpine du TP Docker, avec un.dockerignorequi exclut au moinsnode_modules,.git,.npm,coverageetreports. Relevez la taille de l'image, compressée, dans Deploy > Container Registry, avant et après. -
Ajoutez le cache BuildKit dans le registre, un par branche avec repli sur celui de la branche par défaut :
build:image: variables: BUILDKITD_FLAGS: --oci-worker-no-process-sandbox CACHE_REF: $CI_REGISTRY_IMAGE/cache script: - | buildctl-daemonless.sh build \ --frontend dockerfile.v0 \ --local context=. \ --local dockerfile=. \ --import-cache type=registry,ref=$CACHE_REF:$CI_COMMIT_REF_SLUG \ --import-cache type=registry,ref=$CACHE_REF:$CI_DEFAULT_BRANCH_SLUG \ --export-cache type=registry,ref=$CACHE_REF:$CI_COMMIT_REF_SLUG,mode=max,ignore-error=true \ --output type=image,name=$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA,push=true -
Mettez en cache la base de vulnérabilités de Trivy, et évitez le clone inutile. Donnez aussi à
deploy:stagingune image minimale (alpine:3.24) etGIT_STRATEGY: none: -
Lancez deux pipelines sans modifier le
Dockerfile, puis une après avoir modifié une ligne desrc/.
Ce que vous devez observer¶
- Au premier build, l'import de
.../cache:perf-pipelineéchoue (not found) sans faire échouer le job ; celui de.../cache:mainaussi, tant que la MR n'est pas fusionnée - Aux builds suivants, les étapes de dépendances apparaissent en
CACHED; seule la couche du code change - Une image plus petite se pousse, se tire et se scanne plus vite :
scan:imageraccourcit aussi
Le cache BuildKit n'a pas de séparation protégée
Toute pipeline qui peut pousser dans le registre peut écrire n'importe quel tag de cache. Ici, chaque branche n'exporte que vers son propre tag ; pour aller plus loin, protégez le tag de cache de main avec les Protected container tags (Settings > Packages and registries).
Étape 8 : Analyses différentielles, pipeline nocturne, déploiement sérialisé¶
Si vous avez réalisé l'atelier 6 du TP IAST et DAST authentifié, reprenez vos jobs. Sinon :
-
Sur les MR, ne remontez que les constats introduits par la MR. La détection de secrets n'a pas de règle
changes: un secret peut fuiter dans n'importe quel fichier, et ce job garantit qu'une MR a toujours une pipeline.secrets:gitleaks: needs: [] variables: GIT_DEPTH: "0" rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" script: - gitleaks git --log-opts="$CI_MERGE_REQUEST_DIFF_BASE_SHA..HEAD" --report-format json --report-path gitleaks.json . sast:semgrep: needs: [] variables: GIT_DEPTH: "0" rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" changes: *code-paths script: - semgrep scan --config p/javascript --metrics=off --error --baseline-commit "$CI_MERGE_REQUEST_DIFF_BASE_SHA" --json --output semgrep.json -
Retirez
allow_failure: truedes trois scanners : ils bloquent à nouveau. -
Ajoutez les scans complets pour la nuit, et planifiez-les (Build > Pipeline schedules,
0 2 * * *, fuseauEurope/Paris). Une planification exécute la configuration de sa branche : ciblezperf/pipelinepour tester,mainaprès la fusion.secrets:gitleaks:full: extends: secrets:gitleaks rules: - if: $CI_PIPELINE_SOURCE == "schedule" script: - gitleaks git --report-format json --report-path gitleaks.json . sast:semgrep:full: extends: sast:semgrep rules: - if: $CI_PIPELINE_SOURCE == "schedule" script: - semgrep scan --config p/javascript --metrics=off --error --json --output semgrep.json -
Sérialisez les déploiements : ajoutez
resource_group: stagingàdeploy:staging. -
Vérifiez que la couverture n'a pas baissé : ajoutez à la MR une nouvelle route qui appelle
eval(req.query.expr).sast:semgrepdoit échouer en ne signalant que ce nouveleval(), et non celui desrc/routes/payments.jsdéjà connu. Lancez la planification à la main (bouton Run) : la pipeline planifiée, elle, signale les deux, ainsi que les secrets de l'historique.
Ce que vous devez observer¶
- Les scans de MR sont plus courts et ne remontent que les nouveautés ; le stock est traité la nuit
- Après la fusion, deux pipelines lancées coup sur coup sur
main: le second déploiement attend la fin du premier (waiting_for_resource) au lieu de s'exécuter en même temps
Étape 9 : Dimensionner le runner¶
Choisissez l'option qui correspond à vos runners.
Option A : runners hébergés de GitLab.com¶
- Ajoutez
tags: [saas-linux-medium-amd64]àbuild:image(4 vCPU et 16 Go, facteur de coût 2) et mesurez deux pipelines. - Comparez : pour rester neutre en minutes, le job doit être au moins deux fois plus rapide que sur
small. Gardez lemediumseulement si le gain de durée ressentie le justifie, et écrivez pourquoi.
Option B : runner Kubernetes¶
-
Dans la section
[runners.kubernetes]des valeurs Helm du runner (voir GitLab Runners sur Kubernetes), fixez des requêtes et des limites par défaut (cpu_request,cpu_limit,memory_request,memory_limit) et un plafond pour les surcharges par job (cpu_limit_overwrite_max_allowed = "4",memory_limit_overwrite_max_allowed = "4Gi"), comme dans les slides. -
Donnez plus de ressources au seul build avec
KUBERNETES_CPU_LIMIT: "4"etKUBERNETES_MEMORY_LIMIT: "4Gi", observez la consommation réelle aveckubectl top pod -n <namespace>pendant le job, puis ajustez requêtes et limites aux valeurs observées. - Vérifiez qu'aucun job n'a été tué faute de mémoire (
OOMKilleddanskubectl describe pod) : c'est le signe d'une limite trop basse. Une requête trop haute, elle, se voit à des nœuds réservés mais peu utilisés.
Critère de réussite¶
La configuration de runner retenue est justifiée par une mesure de durée et de coût, pas par une intuition.
Étape 10 : Objectif de durée et rapport¶
Travail demandé¶
Ajoutez scripts/ci-slo.sh, qui calcule la médiane, le p95 et la part sous l'objectif de la durée ressentie des dernières pipelines réussies :
#!/usr/bin/env bash
# Durée ressentie des dernières pipelines réussies : médiane, p95, part sous l'objectif
# Usage : ./ci-slo.sh <id_projet> [source] [nombre]
# source merge_request_event (défaut), push, schedule...
# nombre 30 par défaut (100 au maximum)
# SLO_TARGET objectif de durée en secondes (600 par défaut)
# OFFLINE=1 relit les réponses déjà enregistrées dans metrics/
set -euo pipefail
PROJECT="${1:?usage: $0 <id_projet> [source] [nombre]}"
SOURCE="${2:-merge_request_event}"
COUNT="${3:-30}"
GITLAB_URL="${GITLAB_URL:-https://gitlab.com}"
TARGET="${SLO_TARGET:-600}"
API="$GITLAB_URL/api/v4/projects/$PROJECT/pipelines"
DIR="metrics/slo-$SOURCE"
mkdir -p "$DIR"
if [ -z "${OFFLINE:-}" ]; then
: "${GITLAB_TOKEN:?définissez GITLAB_TOKEN (scope read_api)}"
rm -f "$DIR"/*.json
# La liste ne donne pas les durées : on lit chaque pipeline
for id in $(curl -sSf --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
"$API?source=$SOURCE&status=success&per_page=$COUNT" | jq -r '.[].id'); do
curl -sSf --header "PRIVATE-TOKEN: $GITLAB_TOKEN" "$API/$id" -o "$DIR/$id.json"
done
fi
jq -s -r --argjson target "$TARGET" --arg source "$SOURCE" '
def ts: sub("\\.[0-9]+"; "") | fromdateiso8601;
def hms: round as $s
| if $s < 60 then "\($s) s"
else "\($s / 60 | floor) min \($s % 60 | tostring | if length < 2 then "0" + . else . end) s" end;
def pct($p): sort as $v | $v[(($v | length) * $p | ceil) - 1];
map((.finished_at | ts) - (.created_at | ts)) as $wall
| ($wall | length) as $n
| "## \($n) pipelines \($source) réussies",
"",
"| Indicateur | Valeur |",
"| :--- | ---: |",
"| Durée ressentie médiane (p50) | \($wall | pct(0.5) | hms) |",
"| Durée ressentie p95 | \($wall | pct(0.95) | hms) |",
"| Durée ressentie maximale | \($wall | max | hms) |",
"| Attente médiane avant démarrage | \(map(.queued_duration // 0) | pct(0.5) | hms) |",
"| Part sous l’objectif de \($target | hms) | \([ $wall[] | select(. <= $target) ] | length * 1000 / $n | round / 10) % |"
' "$DIR"/*.json
-
Lancez-le sur vos pipelines de MR :
./scripts/ci-slo.sh "$PROJECT_ID" merge_request_event 10. Les plus anciennes datent des premières étapes : ne gardez que les plus récentes, qui reflètent la configuration finale. Après la fusion, suivez de même les pipelines demain(sourcepush). -
Fixez un objectif, par exemple : « 95 % des pipelines de MR réussies se terminent en moins de 6 minutes, sur 30 jours glissants ». Justifiez le seuil par vos mesures, avec une marge pour la variabilité de l'attente.
-
Rédigez
mesures/rapport.md:- le tableau de suivi complet, et le chemin critique avant et après
- les minutes de calcul et les artefacts par pipeline, avant et après
- les leviers refusés et pourquoi (le parallélisme des tests, par exemple)
- la configuration de runner retenue, avec ses chiffres
- l'objectif de durée, sa mesure, et ce qui se passe s'il n'est plus tenu : optimisation prioritaire, aucun nouveau job sans compensation
- les risques introduits et leurs parades : cache, règles, scans différentiels
Critère de réussite¶
Chaque optimisation est chiffrée, aucune couverture de sécurité n'a disparu, et l'objectif de durée est mesurable par un script versionné.
Pièges fréquents¶
- Cache empoisonné : un cache écrit par une branche non fiable et relu par
main. Gardez la séparation protégée, un seul job écrivain, une clé dérivée du lockfile - Parallélisme excessif : plus de copies que de travail ; la durée ne baisse plus, le temps machine monte
needset artefacts : un job avecneedsne reçoit que les artefacts des jobs listés ; un job sansneedsnidependenciesreçoit ceux de tous les stages précédents- Job attendu mais exclu : un job exclu par ses règles et listé dans un
needssansoptional: trueempêche la création de la pipeline - Historique tronqué : avec un clone superficiel,
gitleaksne trouve pas le commit de base, analyse0 commitset conclutno leaks found. Le scan passe sans rien vérifier : gardezGIT_DEPTH: "0"sur les jobs différentiels - Expiration trop courte : un artefact expiré avant la fin d'une longue pipeline fait échouer les jobs qui le réclament
- Déploiement interruptible : un déploiement coupé à mi-chemin laisse l'environnement dans un état incohérent
Dépannage¶
| Symptôme | Cause probable | Correction |
|---|---|---|
curl: (22) ... 401 ou 404 dans le script | jeton expiré, sans scope read_api, ou mauvais identifiant de projet ou de pipeline | régénérer le jeton, vérifier PROJECT_ID |
| Deux pipelines par push | pas de workflow:rules, ou règles qui acceptent branche et MR | reprendre l'étape 2 |
eslint: not found | needs: [] alors que node_modules/ venait d'un artefact | lister install dans needs, ou installer depuis le cache |
job needs 'test' job, but it was not added to the pipeline | dépendance exclue par ses règles | optional: true sur cette entrée de needs |
Could not resolve a module for a custom reporter. Module name: tests/unit | npm test -- tests/unit : le chemin est pris comme un reporter par l'option --reporters du script | npm test -- --testPathPattern=tests/unit (--testPathPatterns à partir de Jest 30) |
No tests found, exiting with code 1 sur un shard | plus de shards que de fichiers de test | réduire parallel, ou ne pas paralléliser |
failed to configure registry cache importer: ... not found | premier build, le cache n'existe pas encore | rien : le build continue sans cache |
rootlesskit ... operation not permitted | runner auto-hébergé non privilégié | voir l'encadré du TP Docker |
Failed to run 'git cat-file -e <sha>' (Semgrep) | commit de base absent du clone | GIT_DEPTH: "0" sur le job |
| Le cache n'est jamais restauré | clé différente (lockfile modifié), cache protégé contre non protégé, ou aucun job en pull-push | lire la ligne Checking cache for du journal |
could not retrieve the needed artifacts | artefact expiré | allonger expire_in |
Questions de synthèse¶
- Pourquoi optimiser un job hors du chemin critique ne réduit-il pas la durée ressentie ? Que réduit-il ?
- Quels leviers gagnent à la fois sur la durée et sur le coût, et lesquels échangent l'un contre l'autre ?
- Comment GitLab protège-t-il le cache de
maincontre une MR malveillante, et pourquoi le cache BuildKit demande-t-il une vigilance supplémentaire ? - Comment raccourcir les pipelines de MR sans réduire la couverture de sécurité ?
- Quel lien faites-vous entre votre objectif de durée et le lead time for changes des indicateurs DORA ?