CI/CDGitHub ActionsGitLab CIPerformanceIntermédiaire

De 45 minutes à moins de 5 : accélération CI/CD

Jerry RajaonarivoMis à jour le 30 sept. 20269 min de lecture

Un build de 45 minutes ne ressemble pas à une urgence, et c'est pour ça qu'il survit aussi longtemps. Multipliez ces 45 minutes par chaque PR, par chaque push pour réparer une CI en échec et par une équipe d'une douzaine d'ingénieurs. Selon la fréquence des push, on arrive facilement à plus de dix heures-ingénieur par semaine passées à attendre. Cela plafonne aussi votre fréquence de déploiement, puisque personne ne livre plus souvent que ce que permet son pipeline.

J'ai classé les changements ci-dessous dans l'ordre où je les applique. Si vous corrigez la mauvaise étape en premier, vous devrez tout re-mesurer ensuite. Pour garder des chiffres concrets, je suis un pipeline monolithique illustratif, de 45 minutes à moins de 5. Les durées sont un exemple représentatif et non un benchmark. Vos étapes et leurs proportions seront différentes, mais les mêmes leviers s'appliquent.

Où partait le temps

Aucun cache à lui seul n'explique la majorité du temps gagné. L'essentiel du gain vient de la restructuration du pipeline, pour que le travail indépendant s'exécute en parallèle et que rien ne soit fait deux fois.

Remarquez que le pipeline « Avant » build l'application deux fois, d'abord dans l'étape Build dédiée, puis dans le build Docker, dont le Dockerfile lance son propre npm ci et son propre npm run build. C'est très courant. Le pipeline « Après » supprime l'étape Build dédiée, et le build a lieu une seule fois, dans le stage builder de l'image (section 2).

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.

La deuxième erreur la plus courante consiste à mettre node_modules en cache. npm ci supprime node_modules avant d'installer, donc le restaurer fait perdre du temps, et une correspondance partielle via restore-keys peut fournir un arbre de dépendances périmé à un job qui utilise npm install. Mettez plutôt en cache le cache de téléchargement de npm (~/.npm) et laissez npm ci reconstruire node_modules à partir de lui :

yaml
# .github/workflows/ci.yml
- name: Cache npm download cache
  uses: actions/cache@v6
  with:
    path: ~/.npm
    key: npm-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
    restore-keys: |
      npm-${{ runner.os }}-
 
- name: Install dependencies
  run: npm ci --prefer-offline

restore-keys compte autant que key. En cas de quasi-correspondance (un lockfile légèrement différent), il restaure quand même la majorité des archives en cache, et l'installation ne repart pas de zéro. C'est sans risque pour ~/.npm, qui est un stockage adressé par contenu, et npm ci installe toujours exactement ce que dit le lockfile en ne téléchargeant que ce qui manque. Si vous utilisez actions/setup-node, cache: npm s'en charge pour vous, et c'est ce qu'utilise le job de tests plus bas.

2. Cache des layers Docker via le registry cache de BuildKit

Reconstruire l'image complète à chaque push est la deuxième plus grosse perte de temps après l'installation des dépendances. Le cache de BuildKit adossé au registry conserve les layers d'une exécution CI à l'autre. Il a besoin d'un builder Buildx, car le driver docker par défaut du runner ne sait pas exporter de cache (vous obtenez cache export is not supported for the docker driver). Configurez donc Buildx et connectez-vous au registry d'abord :

yaml
- uses: actions/checkout@v7
 
- name: Set up Docker Buildx
  uses: docker/setup-buildx-action@v4
 
- name: Log in to the registry
  uses: docker/login-action@v4
  with:
    registry: registry.example.com
    username: ${{ secrets.REGISTRY_USERNAME }}
    password: ${{ secrets.REGISTRY_PASSWORD }}
 
- name: Build and push with layer cache
  uses: docker/build-push-action@v7
  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=max

Pour les builds multi-stage, il faut mode=max. Il met aussi en cache les layers des stages intermédiaires (le builder ci-dessous), alors que par défaut seules les layers de l'image finale sont mises en cache.

Associez cela à un Dockerfile multi-stage ordonné de sorte que les layers qui changent rarement (installation des dépendances) précèdent celles qui changent fréquemment (code source de l'application). Le stage builder installe tout, y compris les devDependencies dont votre build a besoin (TypeScript, bundlers). Le stage runtime n'installe que les dépendances de production et récupère le résultat du build :

dockerfile
# syntax=docker/dockerfile:1
FROM node:24.21.0-slim AS build
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
 
COPY . .
RUN npm run build
 
FROM node:24.21.0-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
 
# Dépendances de production uniquement, en cache elles aussi tant que le lockfile ne change pas.
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
 
COPY --from=build /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]

Adaptez dist/server.js à ce que produit votre build, et ajoutez node_modules au .dockerignore pour que COPY . . n'embarque pas une installation locale dans le contexte de build. Tant que le lockfile ne change pas, les deux couches npm ci sortent directement du cache, et il ne reste que le npm run build sur le nouveau code. Dans cet exemple, c'est le build de 3 minutes qui tournait auparavant deux fois. Il ne tourne plus qu'une fois et représente l'essentiel des 4 minutes du job d'image.

3. Répartir la suite de tests sur des shards parallèles

Un test en un seul processus ne va pas plus vite parce que votre runner CI a plus de cœurs. Il faut soit dire au test runner de les utiliser, soit répartir le job sur plusieurs runners. Chaque shard est un runner distinct, avec son propre checkout, sa mise en place de Node.js et sa propre installation des dépendances, donc le cache de la section 1 fait gagner ce temps d'installation sur six runners au lieu d'un :

yaml
jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        shard: [1, 2, 3, 4, 5, 6]
    steps:
      - uses: actions/checkout@v7
 
      - uses: actions/setup-node@v7
        with:
          node-version: "24"
          cache: npm
 
      - run: npm ci
 
      - name: Run test shard ${{ matrix.shard }}
        run: npx jest --ci --shard=${{ matrix.shard }}/${{ strategy.job-total }}

--shard nécessite Jest 28 ou plus récent, et strategy.job-total garde le dénominateur synchronisé si vous changez le nombre de shards. Six shards transforment les 20 minutes de tests séquentiels de cet exemple en environ 3 à 3,5 minutes par shard. C'est un peu plus que 20/6, parce qu'il y a toujours un shard plus lent que les autres et que chaque runner a son propre temps de démarrage. Ajoutez les ~45 secondes d'installation en cache et la branche de tests se termine en un peu plus de 4 minutes, en même temps que le build de l'image.

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, tirez d'abord tout ce que vous pouvez du cache et de la parallélisation. Ce sont des investissements quasi ponctuels, alors que des runners plus gros font grimper votre facture linéairement avec l'usage. En général, je ne prends des runners plus gros que pour l'étape de build Docker, où davantage de cœurs CPU accélèrent nettement le build de l'application dans le stage builder.

L'équivalent GitLab CI

Les trois mêmes leviers s'appliquent presque à l'identique sur GitLab. Utilisez needs pour remplacer l'enchaînement par défaut, étape par étape, par un graphe de dépendances. GitLab ne peut mettre en cache que des chemins situés dans le répertoire du projet, donc faites pointer le cache npm à cet endroit :

yaml
test:
  image: node:24.21.0-slim
  parallel: 6
  cache:
    key:
      files:
        - package-lock.json
    paths:
      - .npm/
  script:
    - npm ci --cache .npm --prefer-offline
    - npx jest --ci --shard=$CI_NODE_INDEX/$CI_NODE_TOTAL
 
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.sh

Résultat étape par étape

Ces chiffres correspondent au pipeline illustratif ci-dessus. La colonne « Après » ne s'additionne pas. Les tests et le build de l'image tournent en parallèle, donc la durée totale réelle correspond à la plus lente des deux branches, plus le déploiement.

| Étape | Avant (séquentiel) | Après | |---|---|---| | Installation des dépendances | 8m 00s | 0m 45s par shard (cache npm chaud) | | Build | 3m 00s | plus d'étape dédiée (exécuté une fois dans le build Docker) | | Tests unitaires | 20m 00s | jusqu'à 3m 30s (le plus lent des 6 shards, en parallèle) | | Build Docker | 11m 00s | 4m 00s push compris (layers de dépendances en cache, en parallèle des tests) | | Push + déploiement | 3m 00s | 0m 30s (déploiement seul ; le push se fait dans le job d'image) | | Total (durée réelle) | 45m 00s | ~4m 45s (branche de tests 0m 45s + 3m 30s, puis 0m 30s de déploiement) |

J'applique cette même séquence d'audit et de correction pour mes clients dans une mission d'Accélération CI/CD, en commençant par une décomposition chronométrée de votre propre pipeline.

ALLER PLUS LOIN

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
Me contacterRéponse sous 1 jour ouvré.

Tutoriels similaires