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 :
| 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.