GitLab CI/CD de débutant à expert : construire un pipeline qui teste et déploie tout seul

CI/CD DevOps Docker Git GitLab
GitLab CI/CD de débutant à expert : construire un pipeline qui teste et déploie tout seul

Il y a une étape dans la vie de tout projet où l'on se rend compte que le déploiement repose sur une checklist dans la tête de quelqu'un. Lancer les tests, construire l'image, la pousser, se connecter au serveur, tirer la nouvelle version, redémarrer. Six étapes. Le jour où on en saute une un vendredi soir, le bug part en production.

La CI/CD, c'est précisément ce qui supprime cette checklist mentale. Et GitLab CI a un gros avantage : tout est intégré dans la forge, sans outil externe à brancher. Dans cet article, je reprends mon cours vidéo de A à Z, en ajoutant les exemples de configuration complets que je ne pouvais pas tous montrer à l'écran.

CI, CD : de quoi parle-t-on exactement ?

L'intégration continue (CI) vérifie automatiquement chaque modification : le code compile, les tests passent, le linter ne hurle pas. Le but : détecter un problème quelques minutes après l'avoir introduit, pas trois semaines plus tard.

Le déploiement continu (CD) prend le relais : une fois le code validé, il l'emmène jusqu'au serveur, sans manipulation humaine (ou avec un simple clic de validation pour la production).

L'ensemble forme un pipeline : une chaîne d'étapes déclenchée à chaque push. Si une étape échoue, la chaîne s'arrête et le code défectueux ne va pas plus loin.

Un seul fichier : .gitlab-ci.yml

Tout le pipeline se décrit dans un fichier YAML à la racine du dépôt. Il est versionné avec le code, ce qui veut dire que le pipeline évolue en même temps que le projet, se relit en merge request, et peut être restauré comme n'importe quel fichier.

Les trois briques : jobs, stages, runners

Le job

C'est l'unité de base : un nom, et un script, c'est-à-dire les commandes à exécuter.

tests:
  image: python:3.12-slim
  script:
    - pip install -r requirements.txt
    - pytest

Les stages

Les jobs sont rangés dans des étapes qui s'exécutent dans l'ordre. Les jobs d'un même stage tournent en parallèle.

stages:
  - build
  - test
  - deploy

Les runners

Ce ne sont pas les serveurs GitLab qui exécutent vos commandes, ce sont des runners : des machines qui récupèrent les jobs en attente et les lancent, le plus souvent dans un conteneur Docker. Sur gitlab.com, des runners partagés sont fournis. En auto-hébergé, vous installez gitlab-runner sur une machine et vous l'enregistrez sur votre projet ou votre groupe.

docker run -d --name gitlab-runner --restart always \
  -v /srv/gitlab-runner/config:/etc/gitlab-runner \
  -v /var/run/docker.sock:/var/run/docker.sock \
  gitlab/gitlab-runner:v17.5.0

docker exec -it gitlab-runner gitlab-runner register

Le mot-clé image choisit le conteneur dans lequel tourne chaque job : node:20, python:3.12, etc.

Voir le pipeline tourner

Vous poussez, le pipeline démarre. Dans l'interface, chaque job passe au vert quand il réussit, au rouge quand il échoue. Un clic sur un job affiche son journal, ligne par ligne, exactement comme si vous aviez tapé les commandes dans un terminal. C'est ma première destination quand quelque chose casse.

Merge requests : le pipeline avant la fusion

Le vrai confort arrive avec les merge requests. Le pipeline tourne sur la branche avant la fusion, et le résultat s'affiche directement dans la MR. Dans les paramètres du projet, l'option « Pipelines must succeed » empêche de fusionner tant que ce n'est pas vert. C'est la règle que j'active en premier sur tout nouveau projet.

Variables et secrets

GitLab fournit des dizaines de variables prédéfinies : $CI_COMMIT_BRANCH, $CI_COMMIT_SHORT_SHA, $CI_REGISTRY_IMAGE, $CI_PIPELINE_SOURCE…

Pour vos propres valeurs, et surtout pour les secrets (mot de passe de registre, clé SSH de déploiement, token d'API), passez par Settings → CI/CD → Variables. Cochez :

  • Masked : la valeur est remplacée par [MASKED] dans les journaux ;
  • Protected : la variable n'est disponible que sur les branches et tags protégés. Une branche de feature ne peut donc pas l'exfiltrer.

Règle absolue : aucun secret dans le .gitlab-ci.yml. Il est dans Git, donc dans l'historique, pour toujours.

Artifacts et cache : ce qui passe d'un job à l'autre

Chaque job démarre dans un conteneur neuf. Par défaut, il ne partage rien avec les autres.

  • Artifacts : les fichiers produits par un job (un dist/ compilé, un rapport de tests) et transmis aux jobs des stages suivants. Ils sont aussi téléchargeables depuis l'interface.
  • Cache : les dépendances (node_modules, .pip-cache) conservées d'un pipeline à l'autre pour ne pas tout retélécharger à chaque fois.
build:
  stage: build
  image: node:20-alpine
  cache:
    key:
      files: [package-lock.json]
    paths: [node_modules/]
  script:
    - npm ci
    - npm run build
  artifacts:
    paths: [dist/]
    expire_in: 1 week

La clé de cache basée sur package-lock.json est une astuce que j'utilise partout : le cache est invalidé uniquement quand les dépendances changent vraiment.

Rules : décider quand un job s'exécute

deploy_prod:
  stage: deploy
  script: ./deploy.sh
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

tests_front:
  stage: test
  script: npm test
  rules:
    - changes:
        - front/**/*

Le premier job ne tourne que sur la branche principale. Le second uniquement quand quelque chose a changé dans front/. Sur un monorepo, ça fait gagner un temps considérable.

Environnements et déploiement manuel

deploy_staging:
  stage: deploy
  script: ./deploy.sh staging
  environment:
    name: staging
    url: https://staging.monsite.fr

deploy_production:
  stage: deploy
  script: ./deploy.sh production
  environment:
    name: production
    url: https://monsite.fr
  when: manual
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

GitLab garde l'historique de chaque déploiement par environnement, et permet de redéployer une version précédente en un clic. Le when: manual sur la production ajoute un bouton : le job attend qu'un humain le déclenche. C'est un bon compromis tant que l'équipe n'a pas une confiance totale dans ses tests.

Niveau expert

needs : du pipeline en étages au graphe

Par défaut, un stage attend que tous les jobs du stage précédent soient terminés. Avec needs, un job démarre dès que ses dépendances directes sont finies :

tests_back:
  stage: test
  needs: [build_back]
  script: pytest

Le pipeline devient un graphe orienté (un DAG). Sur un projet avec un front et un back indépendants, les deux chaînes avancent chacune à leur rythme au lieu de s'attendre : le gain de temps est souvent spectaculaire.

extends et include : arrêter le copier-coller

.deploy_template:
  image: alpine:3.20
  before_script:
    - apk add --no-cache openssh-client
  script:
    - ssh deploy@$HOST "cd /srv/app && docker compose pull && docker compose up -d"

deploy_staging:
  extends: .deploy_template
  variables: { HOST: staging.monsite.fr }

include:
  - project: 'infra/ci-templates'
    ref: v2.1.0
    file: '/templates/security.yml'

Les jobs qui commencent par un point sont des modèles cachés, jamais exécutés directement. Et include importe une configuration partagée par tous les projets du groupe, versionnée avec un tag.

parallel:matrix et pipelines parent/enfant

tests:
  image: python:$VERSION
  script: pytest
  parallel:
    matrix:
      - VERSION: ["3.10", "3.11", "3.12"]

Un seul job décrit, trois jobs lancés en parallèle. Et pour les très gros dépôts, un pipeline parent peut déclencher des pipelines enfants (trigger: include:), un par composant, chacun avec son propre fichier.

Un pipeline complet, prêt à adapter

stages: [build, test, deploy]

variables:
  IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA

build_image:
  stage: build
  image: docker:27
  services: [docker:27-dind]
  script:
    - echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin $CI_REGISTRY
    - docker build -t $IMAGE .
    - docker push $IMAGE

unit_tests:
  stage: test
  image: $IMAGE
  needs: [build_image]
  script: pytest -q

deploy_production:
  stage: deploy
  image: alpine:3.20
  needs: [unit_tests]
  environment: { name: production, url: https://monsite.fr }
  when: manual
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
  script:
    - ./scripts/deploy.sh $IMAGE

Les bonnes pratiques que j'applique

  • Un pipeline rapide : sous les dix minutes. Au-delà, les développeurs arrêtent de l'attendre et fusionnent « en espérant ».
  • Des images légères et versionnées : python:3.12-slim, jamais latest. Un pipeline qui casse tout seul un lundi matin parce que l'image a changé, c'est du temps perdu.
  • Zéro secret dans le fichier : variables masquées et protégées.
  • De la sécurité dans chaque pipeline : GitLab fournit des templates prêts à inclure (SAST, détection de secrets, analyse des dépendances).

À retenir

Un fichier .gitlab-ci.yml. Des jobs, rangés en stages, exécutés par des runners. Des variables protégées pour les secrets, des artifacts pour passer les résultats, un cache pour aller vite. Et une production déployée seulement depuis main, avec un clic de validation.

Si vous organisez vos branches avec Git Flow, chaque merge request vers develop passe par ce pipeline : c'est là que les deux outils prennent tout leur sens ensemble.

Retour aux articles DevOps