Aller au contenu

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, needs et 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) et bash sur 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

  1. Créez une branche perf/pipeline et ouvrez une Merge Request en brouillon vers main : toutes les mesures se font sur cette MR.

  2. Ajoutez un linter à SecurePay, qui n'en a pas :

    npm install --save-dev eslint @eslint/js globals
    npm pkg set scripts.lint="eslint ."
    
    // 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 lint signale deux constantes inutilisées dans tests/unit/payment.test.js : utilisez-les dans une assertion ou supprimez-les.

  3. Remplacez .gitlab-ci.yml par la pipeline de départ ci-dessous, volontairement naïve. Gardez pour l'instant le Dockerfile d'origine.

  4. Créez un jeton d'accès personnel (Edit profile > Access tokens) avec le seul scope read_api et une expiration à quelques jours. Notez l'identifiant numérique du projet (Settings > General).

    export GITLAB_TOKEN="glpat-..."   # jamais dans le dépôt
    export PROJECT_ID="12345678"
    
  5. 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"
  1. Modifiez une ligne de src/ (un message de log suffit), poussez, attendez la fin de la pipeline, puis mesurez-la :

    mkdir -p mesures
    PIPELINE_ID=1234567890   # identifiant affiché dans Build > Pipelines
    ./scripts/ci-metrics.sh "$PROJECT_ID" "$PIPELINE_ID" | tee mesures/00-depart.md
    
  2. Recommencez une seconde fois : une mesure isolée ne prouve rien. Retenez pour chaque indicateur la moins favorable des deux, et notez l'écart.

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

  1. Ajoutez la section workflow en tête du fichier, et interruptible: true dans la section default existante :

    workflow:
      auto_cancel:
        on_new_commit: interruptible
      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
    
    default:
      image: node:24-alpine
      interruptible: true
    
  2. Sur deploy:staging, ajoutez interruptible: false : un déploiement commencé ne doit jamais être coupé.

  3. Vérifiez que Settings > CI/CD > General pipelines > Auto-cancel redundant pipelines est coché.

  4. 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 canceled dè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é

  1. Déclarez les dépendances réelles de chaque job. Ajoutez needs: [] à build:image (il n'utilise pas node_modules/), secrets:gitleaks et sast:semgrep, puis :

    lint:
      needs: [install]
    
    test:
      needs: [install]
    
    scan:image:
      needs:
        - job: build:image
          artifacts: false
    
    deploy:staging:
      needs:
        - job: lint
        - job: test
          artifacts: false   # le déploiement n'a que faire des rapports de tests
        - job: scan:image
    
  2. Mesurez, et comparez le chemin critique avec celui de l'étape 1.

  3. Provoquez le piège : passez lint à needs: [] et poussez. Le job échoue avec eslint: not found : sans install dans ses needs, il ne reçoit plus l'artefact node_modules/. Remettez needs: [install].

Ce que vous devez observer

  • Le chemin critique se réduit à build:image, scan:image puis deploy:staging
  • build:image ne télécharge plus node_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é

  1. Supprimez le job install et 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
    
  2. Déplacez GIT_DEPTH: "0" de la section variables globale vers le seul job secrets:gitleaks, qui lit tout l'historique : les autres jobs se contentent de la profondeur par défaut du projet (20).

  3. Lancez deux pipelines : la première remplit le cache, la seconde l'utilise.

Ce que vous devez observer

  • Dans le journal de test, la ligne Checking cache for npm-...-non_protected puis l'extraction du cache ; npm ci est 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 de main

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é

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

    Créez de même .on-image-changes sur *image-paths. lint et test utilisent extends: [.node, .on-code-changes] ; build:image et scan:image utilisent extends: .on-image-changes.

  2. Le déploiement ne se joue que sur main, et seulement si l'image a changé. Un changement du seul Dockerfile n'ajoute ni lint ni test : rendez ces dépendances optionnelles.

    deploy:staging:
      needs:
        - job: lint
          optional: true
        - job: test
          optional: true
        - job: scan:image
      rules:
        - if: $CI_PIPELINE_SOURCE == "schedule"
          when: never
        - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
          changes: *image-paths
    
  3. Poussez une modification du seul README.md, puis du seul Dockerfile, et notez les jobs créés.

Ce que vous devez observer

  • Une modification du seul README.md ne lance plus que les deux scanners, qui n'ont pas encore de règles. Si tous les jobs avaient une règle changes, 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ègle changes
  • Sans optional: true, la création de la pipeline sur main échoue avec 'deploy:staging' job needs 'test' job, but it was not added to the pipeline
  • rules:changes est 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é

  1. Découpez les tests par suite avec une matrice :

    test:
      parallel:
        matrix:
          - SUITE: [unit, integration]
      script:
        - npm test -- --testPathPattern=tests/$SUITE
    
  2. Essayez ensuite une répartition en trois parts : parallel: 3 et npm test -- --shard=$CI_NODE_INDEX/$CI_NODE_TOTAL.

  3. 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 avec No tests found, exiting with code 1 : SecurePay n'a que deux fichiers de test, le troisième shard est vide (--passWithNoTests le ferait passer, sans rien apporter)
  • La durée ressentie ne bouge guère : test n'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é

  1. Remplacez le Dockerfile d'origine (image node:20 complète, plus de 1 Go une fois décompressée) par la version multi-stage sur Alpine du TP Docker, avec un .dockerignore qui exclut au moins node_modules, .git, .npm, coverage et reports. Relevez la taille de l'image, compressée, dans Deploy > Container Registry, avant et après.

  2. 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
    
  3. Mettez en cache la base de vulnérabilités de Trivy, et évitez le clone inutile. Donnez aussi à deploy:staging une image minimale (alpine:3.24) et GIT_STRATEGY: none :

    scan:image:
      variables:
        GIT_STRATEGY: none   # le scan lit l'image dans le registre, pas le dépôt
        TRIVY_USERNAME: $CI_REGISTRY_USER
        TRIVY_PASSWORD: $CI_REGISTRY_PASSWORD
        TRIVY_CACHE_DIR: .trivycache/
        TRIVY_NO_PROGRESS: "true"
      cache:
        key: trivy-db
        paths:
          - .trivycache/
    
  4. Lancez deux pipelines sans modifier le Dockerfile, puis une après avoir modifié une ligne de src/.

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:main aussi, 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:image raccourcit 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 :

  1. 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
    
  2. Retirez allow_failure: true des trois scanners : ils bloquent à nouveau.

  3. Ajoutez les scans complets pour la nuit, et planifiez-les (Build > Pipeline schedules, 0 2 * * *, fuseau Europe/Paris). Une planification exécute la configuration de sa branche : ciblez perf/pipeline pour tester, main aprè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
    
  4. Sérialisez les déploiements : ajoutez resource_group: staging à deploy:staging.

  5. Vérifiez que la couverture n'a pas baissé : ajoutez à la MR une nouvelle route qui appelle eval(req.query.expr). sast:semgrep doit échouer en ne signalant que ce nouvel eval(), et non celui de src/routes/payments.js dé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

  1. Ajoutez tags: [saas-linux-medium-amd64] à build:image (4 vCPU et 16 Go, facteur de coût 2) et mesurez deux pipelines.
  2. Comparez : pour rester neutre en minutes, le job doit être au moins deux fois plus rapide que sur small. Gardez le medium seulement si le gain de durée ressentie le justifie, et écrivez pourquoi.

Option B : runner Kubernetes

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

  2. Donnez plus de ressources au seul build avec KUBERNETES_CPU_LIMIT: "4" et KUBERNETES_MEMORY_LIMIT: "4Gi", observez la consommation réelle avec kubectl top pod -n <namespace> pendant le job, puis ajustez requêtes et limites aux valeurs observées.

  3. Vérifiez qu'aucun job n'a été tué faute de mémoire (OOMKilled dans kubectl 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
  1. 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 de main (source push).

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

  3. 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
  • needs et artefacts : un job avec needs ne reçoit que les artefacts des jobs listés ; un job sans needs ni dependencies reçoit ceux de tous les stages précédents
  • Job attendu mais exclu : un job exclu par ses règles et listé dans un needs sans optional: true empêche la création de la pipeline
  • Historique tronqué : avec un clone superficiel, gitleaks ne trouve pas le commit de base, analyse 0 commits et conclut no leaks found. Le scan passe sans rien vérifier : gardez GIT_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 main contre 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 ?