Mon CI GitLab auto-hébergé était cassé depuis 3 jours — voilà pourquoi (et comment j ai tout remis en ordre)


Trois jours. Trois jours de pipelines rouges sur mon GitLab auto-hébergé, tous en échec, certains en 7 secondes chrono. Et moi qui me disais “ouais ça doit être un truc de réseau” en regardant ailleurs.

Spoiler : c’était pas un truc de réseau.

Le contexte

J’ai un site statique généré avec Astro, hébergé chez moi sur un serveur Unraid. GitLab CI s’occupe de builder le site à chaque push et de l’envoyer vers nginx via rsync. Classique.

Il y a quelques semaines, j’ai migré d’Astro 4 vers Astro 6. Migration propre, site qui tourne, tout baigne. Sauf que j’avais oublié un détail : Astro 6 impose Node.js ≥ 22.12.0. Pas 20. Pas 21. 22 minimum.

Et mon CI ? Il tournait sur node:20-alpine.

Ce que faisait mon CI (aka le hack)

Avant d’aller plus loin, laissez-moi vous montrer le truc qui m’a fait hausser un sourcil quand je l’ai ouvert :

build:
  stage: build
  script:
    - |
      tar -czC "$CI_PROJECT_DIR/sevan-zone-sources" . | \
      docker run --rm -i -w /app node:20-alpine \
        sh -c "mkdir -p /app && tar -xzC /app && npm ci --no-fund && npm run build && tar -czC /app/dist ." \
      | tar -xzC "$CI_PROJECT_DIR/sevan-zone-sources/dist-ci" --one-top-level=dist

Traduction en français des familles :

  1. On compresse les sources du projet dans un tar
  2. On envoie ce tar dans stdin d’un docker run node:20-alpine via un pipe
  3. Le container Node reçoit le tar, l’extrait, fait npm ci && npm run build, puis recompresse le dossier dist/
  4. On récupère ce deuxième tar sur stdout et on l’extrait sur le runner

C’est… inventif. Un peu baroque. Et ça cachait quelque chose.

Pourquoi ce hack existait

Le runner GitLab était configuré en shell executor — c’est-à-dire que les jobs s’exécutent directement dans le bash du container runner. Le runner lui-même n’a pas Node d’installé, donc pour exécuter du Node, quelqu’un a eu l’idée de lancer un container Docker depuis le shell.

Pour que ça marche, le runner monte le socket Docker du host (/var/run/docker.sock) et la CLI docker. Résultat : on peut faire des docker run depuis l’intérieur du runner. C’est du Docker-out-of-Docker, si on veut.

L’astuce du tar en pipe existe parce que le runner est lui-même un container — il ne peut pas simplement monter un volume local vers le container Node qu’il spawn. Alors : stdin/stdout comme canal de transfert.

Ça marche. Mais c’est fragile, c’est difficile à lire, et dans notre cas ça cachait la vraie cause des échecs : Astro 6 lève une erreur au démarrage si Node < 22, avant même de toucher au build.

Le vrai fix

Deux problèmes distincts :

1. La version de Node — remplacer node:20-alpine par node:22-alpine partout. Simple.

2. Un dossier manquant — la commande tar -xzC "dist-ci" échoue si dist-ci n’existe pas encore. Il manquait un mkdir -p avant.

Mais tant qu’on y est, autant régler le problème de fond.

La bonne façon de faire : Docker executor

GitLab Runner supporte plusieurs modes d’exécution. Le Docker executor est le mode natif pour ce genre de setup : le runner crée automatiquement un container par job, y clone le code, exécute les scripts, et le supprime à la fin.

Plus besoin de tar pipe. Plus besoin de docker run manuel dans les scripts.

On change une ligne dans le config.toml du runner :

executor = "docker"

[runners.docker]
  image = "alpine:latest"
  volumes = ["/cache", "/var/run/docker.sock:/var/run/docker.sock"]

Et le .gitlab-ci.yml devient lisible par un humain normal :

stages:
  - test
  - security
  - build
  - deploy

unit-tests:
  stage: test
  image: node:22-alpine
  script:
    - cd sevan-zone-sources
    - npm ci --no-fund
    - npm run test

npm-audit:
  stage: security
  image: node:22-alpine
  allow_failure: true
  script:
    - cd sevan-zone-sources
    - npm ci --no-fund
    - npm audit --audit-level=high || true

build:
  stage: build
  image: node:22-alpine
  script:
    - cd sevan-zone-sources
    - npm ci --no-fund
    - npm run build
  artifacts:
    paths:
      - sevan-zone-sources/dist/
    expire_in: 2 hours
  rules:
    - if: $CI_COMMIT_BRANCH == "master"

deploy:
  stage: deploy
  image: alpine:latest
  needs: [build]
  before_script:
    - apk add --no-cache openssh-client rsync
    - mkdir -p ~/.ssh
    - echo "$SSH_DEPLOY_KEY" > ~/.ssh/id_ed25519
    - chmod 600 ~/.ssh/id_ed25519
  script:
    - rsync -avz --delete
        -e "ssh -p 2222 -o StrictHostKeyChecking=no"
        sevan-zone-sources/dist/
        root@$DEPLOY_HOST:$DEPLOY_PATH/
  rules:
    - if: $CI_COMMIT_BRANCH == "master"
      when: manual
  environment:
    name: production
    url: https://sevan.zone

Chaque job déclare son image. Le runner s’occupe du reste. Les secrets (SSH_DEPLOY_KEY, DEPLOY_HOST, DEPLOY_PATH) sont stockés dans les variables CI/CD de GitLab, pas en dur dans le YAML.

Ce que ça donne concrètement

push sur master
  └── unit-tests   (node:22-alpine)  →  vitest run
  └── npm-audit    (node:22-alpine)  →  npm audit
  └── build        (node:22-alpine)  →  astro build → artifacts
  └── deploy       (alpine:latest)   →  rsync → nginx  [manuel]

Le deploy reste manuel — je veux pouvoir relire un diff avant que ça parte en prod. Le bouton est dans l’UI GitLab.

Ce que j’aurais dû faire depuis le début

Vérifier les engines dans package.json quand je mets à jour un framework majeur. Astro 6 le documente clairement. Mea culpa.

Pour le shell executor vs Docker executor : si vous montez un runner GitLab sur un homelab, partez directement sur le Docker executor. C’est la config standard, c’est ce que les runners partagés de GitLab.com utilisent, c’est documenté partout. Le shell executor c’est bien pour des cas spécifiques (accès à des ressources locales du host, builds natifs sans container), pas pour du CI généraliste.

Voilà. Trois jours de rouge pour deux lignes à changer et une config de runner à corriger. Le genre de truc qu’on règle en dix minutes quand on sait où chercher, et qui traîne des semaines quand on regarde ailleurs.