Skip to content

Gitlab CI + docker executor, une configuration basique pour des projets dockerisés

Auteur : Philippe Le Van - @plv@framapiaf.org

Date : 19 mars 2025

Principe général

  • Mon instance gitlab qui tourne sur gitlab.example.com
  • Dans un projet, à chaque push, gitlab regarde s'il y a un document .gitlab-ci.yml à la racine du projet. S'il y en a un, gitlab crée des jobs.
  • Mon runner tourne sur ci.example.com. Le rôle du gitlab-runner est d'interroger gitlab à intervales réguliers (toutes les 10s par exemple) pour voir s'il y a des nouveaux jobs à lancer. S'il y a un nouveau job à lancer, le gitlab-runner lance un "executor" qui va lancer les commandes de notre CI.
  • Chez moi le gitlab-runner est un container docker basé sur l'image gitlab/gitlab-runner:v17.9.0 : une image fournie par Gitlab
  • Quand un job arrive, ce gitlab-runner lance un "docker executor" dont l'image est kibatic/gitlab-executor. Docker est installé dans l'exécutor et je partage le daemon docker avec les executors. On peut donc lancer des commandes docker dans l'executor.

Warning

Le choix du daemon docker partagé a des implications importantes sur la sécurité. cf le chapitre "Sécurité, conflits et daemon docker"

Note

ci.example.com est un serveur dédié OVH : "KS-5 | Intel Xeon-E3 1270 v6" avec disque MVNE et une option 64Go de RAM. (33€99 HT/mois)

Sécurité, conflits et daemon docker

Sécurité

Il faut avoir confiance en les devs qui pushent sur le repo parce qu'ils on accès au daemon docker de ci.example.com => ils peuvent à peu près tout faire sur ci.example.com depuis un job de la ci.

Warning

Ce système n'est absolument pas à utiliser sur une instance gitlab publique.

conflits

Les jobs lancés dans l'executor partagent le même daemon docker que le host. Pour que les CI fonctionnent, et notamment les partages de volumes, il faut partager certains répertoires entre le host et les executors. Si les executors travaillent dans le même répertoire, il pourra y avoir des conflits entre les jobs.

Nous verrons plus tard comment nous avons palié ce problème.

Mise en place

Création du runner dans gitlab

Cette étape permet de créer un runner dans gitlab avec son token. C'est ce token qui permettra de faire le lien entre le runner et l'instance gitlab.

Dans gitlab > Admin > CI/CD > Runners

  • cliquer sur New Instance Runner
  • Autoriser les untagged jobs
  • Runner description : ci.example.com
  • cliquer sur "Create Runner"
  • bien noter le token du runner

Variable globale GIT_CLONE_PATH pour tous les jobs de gitlab-ci

On crée une variable globale à Gitlab qui va être envoyée à tous les jobs de la CI. Cette variable dit à Gitlab où doivent être clonés les dépôts pour chaque job. Ici, on choisit d'avoir un répertoire séparé pour chaque job. C'est pour ça qu'on met $CI_JOB_ID dans le chemin.

Dans gitlab > Admin > Settings > CI/CD > Variables

  • cliquer sur "Add Variable"
  • bien cocher "Expand variable reference"
  • Key : GIT_CLONE_PATH
  • Value : $CI_BUILDS_DIR/gitlab-job-$CI_JOB_ID

Note

Tous les jobs ont accès au $CI_BUILD_DIR qui est partagé avec le host. Un job a donc accès au répertoire des autres jobs. C'est pour ça qu'on utilise des répertoires spécifiques à chaque job pour éviter les conflits entre les jobs.

Déploiement du runner avec Ansible

On déploie l'unit suivante, explication dans les commentaires

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
systemd_units:
  - name: gitlab-runner
    # Image du runner correspondant à la version de GitLab
    # le runner est utilisé pour lancer les jobs de CI/CD
    # la configuration du runner est stockée dans le fichier config/config.toml.j2
    image: gitlab/gitlab-runner:v17.9.0
    host_copy:
      - src: vars/units/gitlab-runner/config/config.toml.j2
        dest: config.toml
        template: true
    volumes:
      # on partage de socket du daemon docker pour qu'il soit
      # partagé avec le container gitlab/gitlab-runner
      # ensuite, le daemon est de nouveau partagé avec
      # l'executor kibatic/gitlab-executor pour pouvoir lancer des
      # commandes docker dans les jobs de CI/CD
      - /var/run/docker.sock:/var/run/docker.sock
      # on monte la configuration de runner
      - config.toml:/etc/gitlab-runner/config.toml
      # on enregistre les caches des jobs de CI/CD sur le host
      - /gitlab-cache:/cache

Avec la config config.toml suivante :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
# nombre de job concurrents max
concurrent = 100

# raisonnablement verbeux, on peut le laisser temporairement à info
log_level = "info"

# format du log. text, c'est simple et lisible
log_format = "text"

# c'est le runner qui interroge à intervales régulier Gitlab pour savoir s'il
# y a un nouveau job et pour envoyer de nouvelles données à l'interface.
# par défaut c'est 10s, mais c'est cool de mettre 3s pour avoir des jobs plus rapides.
check_interval = 3 # Value in seconds

[[runners]]
  # nom du runner pour affichage dans l'interface de gitlab.
  name = "kibatic_runner"
  # url de gitlab avec qui communique le runner.
  url = "https://gitlab.example.com"
  # type d'executor. Ça dit que pour chaque job, le runner va lancer un conteneur docker
  # défini dans "image"
  executor = "docker"
  # on  limite à 10 le nombre de jobs concurrents avec ce runner.
  limit = 10
  # Le token est créé par Gitlab quand crée un runner dans l'interface web (runner d'instance,
  # de groupe ou de projet, peu importe).
  token = "{{ gitlab_runner.token }}"

  # post_build_script : lancé dans l'exécutor à la fin du traitement, mais
  # avant le save du cache (du coup on ne peut pas effacer le répertoire de clone
  # ici parce que sinon le cache ne peut pas être saved).
  post_build_script = """
    STACK_ID="gitlab-job-$CI_JOB_ID"
    echo 'post_build_script stopping docker compose -p ${STACK_ID} down if it exists and if docker exists'
    if command -v docker 2>&1 >/dev/null
    then
      docker compose -p $STACK_ID down
    fi
    # echo 'post_build_script removing /builds/${STACK_ID}[.tmp] if it exists'
    # rm -rf /builds/${STACK_ID} /builds/${STACK_ID}.tmp
    echo 'post_build_script end of cleaning of $STACK_ID'
  """

  # ici on configure ce qui se passe spécifiquement pour le container créé pour chaque job
  [runners.docker]
    # le container doit être privileged, je ne sais pas trop pourquoi
    privileged = true
    # L'image docker qui est lancée par le runner.
    # c'est une image crée par Kibatic. Un debian avec docker et docker compose installés
    # Docker utilise de daemon docker du host (déjà monté dans le runner)
    image = "kibatic/gitlab-executor"
    # a priori inutile parce qu'on passe par le socket et pas par le réseau pour
    # communiquer avec le daemon docker du host
    tls_verify = false
    # pour l'instant je ne vois pas de raison de disable le cache.
    disable_cache = false
    # on monte :
    # - le socker du daemon docker du host (déjà monté dans le runner)
    # - le dossier /cache pour les caches des jobs de CI (node_modules, vendor, etc)
    # - le dossier /builds pour les sources du projet : on est obligé de le monter ici
    # sinon les montages de répertoires dans les docker-compose.yml ne vont pas fonctionner
    # parce que le daemon tourne sur le host et pas dans le container.
    # - le dossier /tmp pour les fichiers temporaires. Même raison que le dossier /builds
    # - le dossier /root/.ssh pour les clés ssh. Je ne suis pas certain que ça soit utile.
    volumes = ["/var/run/docker.sock:/var/run/docker.sock", "/cache:/cache", "/builds:/builds", "/tmp:/tmp", "/root/.ssh:/root/.ssh"]
    # quand un job est lancé, il doit faire d'abord un pull de l'executor kibatic/gitlab-runner
    # potentiellement il peut faire d'autres docker pull. Dans la CI, on peut choisir
    # de faire systématiquemet le pull ou de le faire seulement si l'image n'est pas présente.
    allowed_pull_policies = ["always", "if-not-present"]
    # par défaut on ne fait le pull que si l'image n'est pas présente dans /var/lib/docker
    pull_policy = "if-not-present"
  [runners.cache]
    # Je ne sais pas ce que fait cette config
    Insecure = false

Les systèmes de cleanup :

On a deux systèmes de cleanup :

Cleanup en direct à la fin des builds

À la fin de chaque job, on lance le post_build_script qui fait un cleanup d'un éventuel docker compose qui aurait été lancé dans le job.

C'est le mini-script défini dans le paramêtre post_build_script du fichier config.toml

cleanup à 4h du matin

Toutes les nuits à 4h du matin, on lance le script /ci-cleaner.sh qui fait le ménage :

  • efface des docker-compose qui n'auraient pas été effacés
  • efface les répertoires de clone dans /builds (ceux qui on plus d'1h)
  • efface les volumes pas utilisés

ci-cleaner.sh, lancé tous les matins à 4h

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
#!/bin/bash

# on parcourt les fichiers dans /builds qui contiennent gitlab-job- et qui ont plus de 1h
# et on les efface.
for dirs in `find /builds -maxdepth 1 -name "*gitlab-job-*" -mmin +60`;
do
  rm -rf $dirs
done

# on parcourt toutes les "stack" docker compose qui ont pour nom
# gitlab-jobs et on les supprime
for stacks in `docker compose ls --quiet | grep "gitlab-job"`
do
  docker compose -p $stacks down
done

# remove all unused volumes
docker volume prune -a -f

cron dans ansible :

1
2
3
4
5
6
cron:
  ci-cleaner:
    job: '/ci-cleaner.sh > /dev/null'
    minute: 0
    hour: 4
    healthcheck: 077e1ee7-3bdf-447e-837b-32745e4b5fca

Exemple de gitlab-ci.yml

Le fichier suivant est juste un exemple de fichier .gitlab-ci.yml pour donner une idée de ce qu'on fait avec nos CI.

Ici la CI a 4 étapes :

  • build-for-tests : on crée une image "pour tests en CI" qui ne dépend pas du code. Elle n'est rebuildée que lorsque le Dockerfile, le dockerignore, le répertoire ./docker/ ou quelques autres fichiers sont modifiés. Dans la vie courante du projet, cette image n'est pas reconstruite très souvent.
  • tests : on lance les tests avec docker-compose
  • build (seulement pour les branches staging et prod) : on crée l'image qui sera utilisée pour les déploiements. Elle ressemble à l'image pour les tests en CI, mais on ajoute le code dans l'image pour qu'elle soit autonome en prod.
  • deploy-prod, deploy-staging (branches staging et prod only) : on déploie l'image sur un cluster swarm

Note

Le déploiement se fait en SSH. Pour se connecter, on ajoute dans les variables d'environnement de la CI/CD dans les settings du projet une variable SSH_PRIVATE_KEY. Elle contient la clé privée qui permet de se connecter au serveur de déploiement. Cette clé est montée par le start.sh du kibatic/gitlab-executor.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
variables:
  # note : l'utilisation de FastZip et le "compression level" à fast
  # permet d'accélérer la mise en cache
  CACHE_COMPRESSION_LEVEL: "fast"
  FF_USE_FASTZIP: 1
  COMPOSE_FILE: docker-compose.yml:docker-compose.ci.yml

stages:
  - prebuild
  - tests
  - build
  - deploy

before_script:
  - id
  - docker info
  - docker-compose version
  - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
  - export DOCKER_PREBUILD_IMAGE_TAG=$(tar
    --sort=name
    --owner=root:0
    --group=root:0
    --mtime='2020-01-01 00:00:00'
    -cvf -
    ./.dockerignore ./docker-compose.ci.yml ./.gitlab-ci.yml ./Dockerfile ./docker/
    | sha1sum | cut -f 1 -d " ")
  - echo "DOCKER_IMAGE_TAG=${DOCKER_IMAGE_TAG}"

# --- Pre-build (sans code) ---
build-for-tests:
  stage: prebuild
  only:
    refs:
      - main
      - staging
      - prod
      - merge_requests
      - tags
  interruptible: true
  script:
    - |
      echo "Check if image $CI_REGISTRY_IMAGE/web:ci-${DOCKER_PREBUILD_IMAGE_TAG} exist and rebuild if not"
      IMAGE_FOUND=YES
      docker pull $CI_REGISTRY_IMAGE/web:ci-${DOCKER_PREBUILD_IMAGE_TAG} || IMAGE_FOUND="NO"
      if [ ${IMAGE_FOUND} == "NO" ]; then
        echo "image does not exist. rebuilding."
        docker pull $CI_REGISTRY_IMAGE/web:ci-latest || true
        docker build --cache-from $CI_REGISTRY_IMAGE/web:ci-latest --pull --target base -t $CI_REGISTRY_IMAGE/web:ci-${DOCKER_PREBUILD_IMAGE_TAG} .
        docker push $CI_REGISTRY_IMAGE/web:ci-${DOCKER_PREBUILD_IMAGE_TAG}
        docker tag $CI_REGISTRY_IMAGE/web:ci-${DOCKER_PREBUILD_IMAGE_TAG} $CI_REGISTRY_IMAGE/web:ci-latest
        docker push $CI_REGISTRY_IMAGE/web:ci-latest
      else
        echo "Image already exists, no need to rebuild."
      fi

tests:
  stage: tests
  only:
    refs:
      - main
      - staging
      - prod
      - merge_requests
      - tags
  cache:
    - key:
        files:
          - composer.lock
      paths:
        - vendor
      policy: pull-push
    - key:
        files:
          - yarn.lock
          - composer.lock # La partie PHP de Symfony UX peut influencer les dépendances JS
      paths:
        - node_modules
      policy: pull-push
  interruptible: true
  variables:
    APP_ENV: test
  script:
    - docker-compose up -d
    # --- Dépendances ---
    - docker-compose run --rm web composer install --no-interaction --no-progress --no-scripts --prefer-dist
    - docker-compose run --rm node yarn install --frozen-lockfile
    - docker-compose run --rm node yarn encore prod
    - make permissions
    # --- Initialisation bdd ---
    - docker-compose run --rm web php bin/console doctrine:database:drop --if-exists --no-interaction --force --env=test
    - docker-compose run --rm web php bin/console doctrine:database:create --if-not-exists --no-interaction --env=test
    - docker-compose run --rm web php bin/console doctrine:migrations:migrate --no-interaction --quiet --env=test
    - docker-compose run --rm web php bin/console doctrine:fixtures:load --no-interaction  --append --quiet --env=test
    # --- Tests ---
    - docker-compose exec web php vendor/bin/phpunit

build:
  stage: build
  cache:
    -   key:
          files:
            - composer.lock
        paths:
          - vendor
        policy: pull
    -   key:
          files:
            - yarn.lock
            - composer.lock # La partie PHP de Symfony UX peut influencer les dépendances JS
        paths:
          - node_modules
        policy: pull
  only:
    refs:
      - staging
      - prod
  variables:
    DOCKER_IMAGE_TAG: $CI_COMMIT_REF_NAME
  script:
    # --- Fichier de version ---
    - export JSON_TEMPLATE='{"branch":"%s","hash":"%s","built_at":"%s"}\n'
    - printf "$JSON_TEMPLATE" "$CI_COMMIT_REF_NAME" "$CI_COMMIT_SHORT_SHA" "$(date)" > public/version.json
    - cat public/version.json
    # --- Dépendances ---
    - docker-compose run --rm web composer install --no-interaction --no-progress --prefer-dist --optimize-autoloader # attention à ne pas utiliser --no-script à cette étape
    - docker-compose run --rm node yarn install --frozen-lockfile
    - docker-compose run --rm node yarn encore prod
    # --- Docker Build & Push ---
    - docker build --cache-from ${CI_REGISTRY_IMAGE}/web:ci-${DOCKER_PREBUILD_IMAGE_TAG} --pull --target web-prod -t ${CI_REGISTRY_IMAGE}/web:${DOCKER_IMAGE_TAG} .
    - docker build --cache-from ${CI_REGISTRY_IMAGE}/web:ci-${DOCKER_PREBUILD_IMAGE_TAG} --pull --target worker-prod -t ${CI_REGISTRY_IMAGE}/worker:${DOCKER_IMAGE_TAG} .
    - docker push ${CI_REGISTRY_IMAGE}/web:${DOCKER_IMAGE_TAG}
    - docker push ${CI_REGISTRY_IMAGE}/worker:${DOCKER_IMAGE_TAG}

deploy-staging:
  stage: deploy
  environment:
    name: staging
  only:
    refs:
      - staging
  variables:
    DEPLOY_USER: gitlab-runner
    DEPLOY_HOST: deployserver.example.com
    DEPLOY_STACK: myproject-staging
    DOCKER_IMAGE_TAG: staging
    DOMAIN: myproject-test.example.com
    ODOO_API_URL: https://odoo-preprod.example.com
    ODOO_API_DB: passwd_odoo
    LOTEMPLATE_API_URL: http://lotemplate:8000
    LOTEMPLATE_SECRET_KEY: yyyy
    POSTGRES_PASSWORD: $DB_PASSWORD_STAGING
    SENTRY_ENV: staging
    SWARM_REPLICAS: 2
  script:
    - export DOCKER_HOST="ssh://${DEPLOY_USER:-$USER}@${DEPLOY_HOST:-$1}"
    - docker stack deploy --with-registry-auth --prune -c deploy/docker-compose.yml ${DEPLOY_STACK:-$2}
    - echo "Déploiement terminé."

deploy-prod:
  stage: deploy
  environment:
    name: prod
  only:
    refs:
      - prod
  variables:
    DEPLOY_USER: gitlab-runner
    DEPLOY_HOST: deployserver.kitservice.net
    DEPLOY_STACK: myproject-prod
    DOCKER_IMAGE_TAG: prod
    DOMAIN: myproject.example.com
    ODOO_API_URL: https://odoo.example.com
    ODOO_API_DB: passwd_odoo
    LOTEMPLATE_API_URL: http://lotemplate:8000
    LOTEMPLATE_SECRET_KEY: xxxxx
    POSTGRES_PASSWORD: $DB_PASSWORD_PROD
    SENTRY_ENV: prod
    SWARM_REPLICAS: 6
  script:
    - export DOCKER_HOST="ssh://${DEPLOY_USER:-$USER}@${DEPLOY_HOST:-$1}"
    - docker stack deploy --with-registry-auth --prune -c deploy/docker-compose.yml ${DEPLOY_STACK:-$2}
    - echo "Déploiement terminé."

Reste à faire

  • [x] Créer un de cleanup à la fin des jobs : https://docs.gitlab.com/runner/configuration/advanced-configuration/ On utilise un post_build_script dans config.toml
  • [x] Cleanup des containers docker-compose
  • [ ] Cleanup des répertoires du répertoire /builds(impossible, lancé avant le save du cache)
  • [ ] Créer un script de cleanup en cron pour les post_build_script qui n'ont pas fonctionné
    • [x] Cleanup des containers docker-compose
    • [x] Cleanup des répertoires du répertoire /builds
    • [x] Cleanup des volumes (docker system prune -a)
    • [ ] Voir si on peut faire intelligeament un cleanup du cache.
  • [ ] Vérifier qu'on ne fait pas n'importe quoi avec le répertoire /cache dans le runner.
  • [x] Déployer le runner pour que le cache arrive sur le host
  • [x] Limiter les IP du daemon docker pour faire marcher les VPN sans risque
  • [x] Dans /build, mettre l'id du job en 1er pour pouvoir plus facilement faire le ménage.