De 45 minutes à moins de 5 : accélération CI/CD
Un build de 45 minutes ne ressemble pas à une urgence, ce qui explique pourquoi il survit aussi longtemps. Mais faites le calcul : 45 minutes, multipliées par chaque PR, multipliées par chaque nouvelle tentative pour corriger la CI, multipliées par une équipe d'une douzaine d'ingénieurs. Cela représente facilement 15 à 20 heures-ingénieur brûlées chaque semaine, rien qu'en attente. Cela plafonne aussi directement votre fréquence de déploiement, puisque personne ne livre plus souvent que ce que permet son pipeline.
Les changements ci-dessous sont listés dans l'ordre où nous les avons appliqués, car l'ordre compte : corriger la mauvaise étape en premier vous fait perdre du temps à re-mesurer ensuite. Cette séquence a fait passer un pipeline monolithique de 45 minutes à 4 minutes 30.
Où partait le temps
Aucun cache à lui seul n'explique la majorité du temps gagné, le vrai changement est structurel : transformer un pipeline entièrement séquentiel en un pipeline où le travail indépendant s'exécute en parallèle.
1. Cache des dépendances, correctement indexé
L'erreur de cache la plus courante : indexer le cache sur le nom de la branche plutôt que sur le hash du lockfile. Résultat : le cache ne matche presque jamais sur une branche fraîche, juste quand vous en avez le plus besoin.
# .github/workflows/ci.yml
- name: Cache node_modules
uses: actions/cache@v4
with:
path: |
~/.npm
node_modules
key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
restore-keys: |
npm-${{ runner.os }}-restore-keys compte autant que key ; cela permet à une quasi-correspondance (un lockfile légèrement différent) de restaurer quand même la majorité du cache, au lieu de repartir de zéro.
2. Cache des layers Docker via le registry cache de BuildKit
Reconstruire l'image complète à chaque push est le deuxième plus gros gouffre à temps après l'installation des dépendances. Le cache adossé au registry de BuildKit conserve les layers d'une exécution CI à l'autre :
- name: Build and push with layer cache
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: registry.example.com/app:${{ github.sha }}
cache-from: type=registry,ref=registry.example.com/app:buildcache
cache-to: type=registry,ref=registry.example.com/app:buildcache,mode=maxAssociez cela à un Dockerfile ordonné de sorte que les layers qui changent rarement (paquets système, installation des dépendances) précèdent celles qui changent fréquemment (code source de l'application) :
FROM node:20-slim
WORKDIR /app
# Les dépendances changent bien moins souvent que le code source,
# installez-les en premier pour que cette couche reste en cache
# sur la majorité des commits.
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
RUN npm run build3. Répartir la suite de tests sur des shards parallèles
Un test en un seul processus ne va pas plus vite juste parce que votre runner CI a 8 cœurs. Il faut explicitement dire au test runner de les utiliser, ou répartir le job sur plusieurs runners :
jobs:
test:
strategy:
matrix:
shard: [1, 2, 3, 4]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run test shard ${{ matrix.shard }}
run: npx jest --shard=${{ matrix.shard }}/4Quatre shards sur quatre runners parallèles transforment un test séquentiel de 15 minutes en environ 4 minutes. Pas exactement 15/4 (il y a toujours un shard plus lent que les autres), mais suffisamment proche pour changer la donne.
4. Dimensionnement des runners : plus gros n'est pas toujours la solution
Avant de vous tourner vers des runners auto-hébergés ou plus puissants, épuisez d'abord le cache et la parallélisation : ce sont des investissements quasi ponctuels, alors que des runners plus gros font grimper votre facture linéairement avec l'usage. Nous ne recourons à des runners plus gros que pour l'étape de build Docker, où plus de cœurs CPU accélère significativement les builds multi-stage.
L'équivalent GitLab CI
Les trois mêmes leviers s'appliquent presque à l'identique sur GitLab, avec needs utilisé pour transformer la sérialisation par défaut étape par étape en un véritable graphe de dépendances :
test:
parallel: 4
script:
- npx jest --shard=$CI_NODE_INDEX/$CI_NODE_TOTAL
cache:
key:
files:
- package-lock.json
paths:
- node_modules/
deploy:
needs: ["test", "docker-build"] # démarre dès que les deux sont terminés, pas après chaque étape précédente
script:
- ./deploy.shRésultat étape par étape
| Étape | Avant | Après | |---|---|---| | Installation des dépendances | 8m 00s | 0m 45s | | Build | 12m 00s | inclus dans le build en cache | | Tests unitaires | 15m 00s | 2m 10s (parallèle x4) | | Build Docker | 7m 00s | 1m 10s (cache de layers) | | Push + déploiement | 3m 00s | 1m 05s | | Total | 45m 00s | 4m 30s |
J'applique cette même séquence d'audit et de correction pour mes clients dans une mission d'Accélération CI/CD : une décomposition chronométrée de votre pipeline, pas une checklist générique.
Envie de faire tourner ça en production ?
Ce tutoriel couvre les concepts et l'architecture. Si vous voulez l'implémenter dans votre propre infrastructure, ou monter en compétence pour posséder ce sujet durablement, je propose du mentorat individuel construit autour de votre environnement réel, pas une formation générique.
Ce tutoriel
- Concepts clés et architecture principale
- Extraits de code illustratifs
- Le raisonnement derrière chaque décision
Mentorat individuel
- Des sessions de travail sur votre propre environnement
- Des réponses directes aux cas particuliers que vous rencontrez
- Un retour sur votre implémentation réelle
- Un accompagnement continu pendant que vous la construisez