Infrastructure

Réduire le coût et la durée de vos pipelines GitLab avec des runners autoscalés

Runners Kubernetes ou spot, cache pnpm et Docker, jobs parallèles avec needs, rules ciblées : réduisez la facture et la durée de vos pipelines GitLab CI.

CloudCorner5 min de lecture

Une pipeline lente coûte deux fois : en factures d'infrastructure, et en temps d'attente des développeurs. Sur certaines missions, les optimisations décrites ci-dessous nous ont permis de gagner jusqu'à 40 % de temps de build, tout en réduisant la facture de calcul. Aucune n'est exotique : il s'agit surtout d'arrêter de payer pour du calcul inutilisé et de ne plus refaire ce qui a déjà été fait.

Commencer par mesurer

Avant de modifier quoi que ce soit, établissez une base de référence. Sans elle, impossible de savoir si un changement aide.

  • La durée médiane et le 90e centile des pipelines de la branche principale.
  • Le temps passé en file d'attente avant le démarrage d'un job.
  • Les jobs les plus longs et les plus fréquents (ce sont eux qui paient).
  • Le coût de calcul mensuel des runners, ramené au nombre de pipelines.

GitLab expose ces données via les analyses CI/CD et l'API. Notez-les avant, comparez après chaque changement, et ne cumulez pas dix modifications d'un coup : vous ne saurez plus laquelle a fonctionné.

Des runners qui s'adaptent à la charge

Des machines allumées en permanence pour des pipelines qui tournent quelques heures par jour sont la première source de gaspillage. L'autoscaling règle le problème : les ressources n'existent que pendant les jobs.

Runners sur Kubernetes

L'exécuteur Kubernetes lance chaque job dans un pod éphémère. Combiné à un autoscaler de nœuds (Cluster Autoscaler ou Karpenter), le cluster grossit quand la file se remplit et se réduit à presque rien la nuit. Voici un extrait de values.yaml pour le chart Helm gitlab-runner :

gitlabUrl: https://gitlab.example.org/
concurrent: 20
checkInterval: 3

rbac:
  create: true

runners:
  secret: gitlab-runner-token
  config: |
    [[runners]]
      [runners.kubernetes]
        namespace = "gitlab-runner"
        image = "alpine:3.21"
        privileged = false
        cpu_request = "500m"
        memory_request = "1Gi"
        cpu_limit = "2"
        memory_limit = "4Gi"
        [runners.kubernetes.node_selector]
          "workload" = "ci"
        [[runners.kubernetes.node_tolerations]]
          key = "ci"
          operator = "Equal"
          value = "true"
          effect = "NoSchedule"

Le jeton du runner est lu depuis un secret Kubernetes, jamais écrit dans les values. Le node_selector et la tolérance dirigent les jobs vers un pool de nœuds dédié, facile à réduire à zéro. Définissez toujours des requests et des limits : sans elles, un job gourmand déstabilise ses voisins et l'autoscaler dimensionne mal.

Pour construire des images sans mode privilégié, préférez Kaniko, BuildKit en mode rootless ou Buildah plutôt que Docker-in-Docker.

Instances spot ou préemptibles

Les instances spot (AWS), préemptibles (GCP) ou Spot VMs (Azure) coûtent nettement moins cher que le tarif à la demande, avec une contrepartie : le fournisseur peut les reprendre à tout moment. Les jobs de CI s'y prêtent bien, à condition de :

  • les rendre idempotents, donc relançables sans effet de bord ;
  • activer les relances automatiques ciblées sur les échecs d'infrastructure ;
  • garder un petit pool à la demande pour les jobs critiques, comme le déploiement en production.
default:
  retry:
    max: 2
    when:
      - runner_system_failure
      - stuck_or_timeout_failure
      - scheduler_failure

On ne relance ainsi que les échecs dus à la plateforme, jamais les tests rouges.

Ne plus rien refaire : le cache

Cache des dépendances pnpm

Le magasin de pnpm est un excellent candidat : il est adressé par contenu et partagé entre les projets. En le plaçant dans le cache GitLab, un pnpm install devient presque instantané.

variables:
  PNPM_STORE: "$CI_PROJECT_DIR/.pnpm-store"

.node-base:
  image: node:22-alpine
  before_script:
    - corepack enable
    - pnpm config set store-dir "$PNPM_STORE"
  cache:
    key:
      files:
        - pnpm-lock.yaml
    paths:
      - .pnpm-store
    policy: pull

install-deps:
  extends: .node-base
  stage: .pre
  script:
    - pnpm install --frozen-lockfile
  cache:
    key:
      files:
        - pnpm-lock.yaml
    paths:
      - .pnpm-store
    policy: pull-push

La clé de cache dérive du fichier pnpm-lock.yaml : le cache n'est renouvelé que si les dépendances changent. Un seul job écrit (pull-push), tous les autres se contentent de lire (pull), ce qui évite les écritures concurrentes.

Cache des layers Docker

Pour les builds d'images, un registre de cache évite de reconstruire les couches inchangées :

docker buildx build \
  --cache-from type=registry,ref="$CI_REGISTRY_IMAGE:buildcache" \
  --cache-to type=registry,ref="$CI_REGISTRY_IMAGE:buildcache",mode=max \
  --tag "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA" \
  --push .

Ce cache n'est efficace que si le Dockerfile est bien ordonné : installer les dépendances avant de copier le code source, pour que la couche lourde ne soit invalidée que lorsque le lockfile change.

Paralléliser avec needs et cibler avec rules

Par défaut, GitLab attend la fin de tous les jobs d'une étape avant de passer à la suivante. Le mot-clé needs construit un graphe de dépendances : un job démarre dès que ceux dont il dépend sont terminés.

lint:
  extends: .node-base
  stage: test
  needs: ["install-deps"]
  script: pnpm lint

unit-tests:
  extends: .node-base
  stage: test
  needs: ["install-deps"]
  parallel: 4
  script: pnpm test --shard=$CI_NODE_INDEX/$CI_NODE_TOTAL

build:
  extends: .node-base
  stage: build
  needs: ["install-deps"]
  script: pnpm build
  rules:
    - changes:
        - src/**/*
        - package.json
        - pnpm-lock.yaml

Ici, lint, tests et build s'exécutent en même temps, et les tests sont répartis en quatre tranches avec parallel. Les rules avec changes évitent de lancer un build quand seule la documentation a été modifiée. Dans un monorepo, c'est souvent le gain le plus spectaculaire.

Quelques règles complémentaires :

  • annuler automatiquement les pipelines redondants avec interruptible: true ;
  • ne pas lancer les jobs lourds (e2e, scans complets) sur chaque commit de branche, mais en merge request ou de façon planifiée ;
  • éviter les pipelines doublons entre branche et merge request avec workflow:rules.

Des images légères

Une image de 1,5 Go se télécharge à chaque job sur un runner éphémère. Passer à des variantes Alpine ou « slim », construire une image d'outillage dédiée avec uniquement ce qui sert, et héberger ces images dans un registre proche des runners ou derrière un proxy de cache réduit sensiblement le temps de démarrage. C'est un gain discret mais multiplié par chaque job de chaque pipeline.

Vérifier le résultat

Après chaque changement, comparez à votre base de référence : durée médiane, temps d'attente, coût par pipeline. Gardez ce qui fonctionne, retirez ce qui complexifie sans rapporter. L'optimisation de la CI est un travail continu, pas un chantier ponctuel.

Besoin d'un coup de main ?

Dimensionner des runners autoscalés, fiabiliser l'usage du spot ou auditer vos pipelines pour identifier les gains les plus rentables fait partie de nos missions habituelles. Pour en parler, vous pouvez nous contacter.

#gitlab#ci-cd#kubernetes#optimisation

Un projet similaire ?

Parlons-en : un expert vous répond sous 24 heures ouvrées.

Nous contacter