Construction d'images Docker sur GitLab CI

Publié le 26/09/2020 à 19:14. Mise à jour le 25/04/2021 à 13:28.

J'ai décidé de revoir ma pipeline actuelle et d'utiliser mon propre GitLab Runner. Ce fût plus compliqué que prévu.

Construction d'images Docker sur GitLab CI

Photo par Chris Liverani sur Unsplash


Récemment, Docker Hub a annoncé sa nouvelle politique de rétention d'images visant à supprimer les images inutilisées depuis 6 mois et GitLab la baisse des minutes de build passant à 400 minutes.
Bon, ça ne concerne que les comptes utilisateurs gratuits et ces contraintes ne sont pas bloquantes pour mes projets, mais ça m'a motivé à mettre en place mon propre runner et à me débarasser de Docker Compose en CI.

Pour ceux qui ne seraient pas familier avec les technologies mentionnées ici:

  • l'intégration continue (ou CI pour « Continuous Integration ») est une pratique visant à tester une application en permanence afin de détecter des erreurs le plus tôt possible.
  • GitLab CI est donc un outil permettant de pratiquer l'intégration continue, en général lorsque le code est envoyé vers le dépôt Git, une pipeline est déclenchée.
  • un runner est responsable de l'exécution d'une pipeline, GitLab propose les siens (les « shared runners ») mais on peut installer son propre runner.
  • Docker est une technologie de conteneurisation, de nombreux outils de CI exécutent les jobs (les tâches d'une pipeline) dans des conteneurs pour avoir un environnement isolé.
  • Docker Compose est un outil facilitant la gestion d'un ensemble de conteneurs.

Et Docker in Docker (abregé « DinD » 🦃) consiste à faire tourner Docker dans un conteneur Docker. C'est intéressant puisque je souhaite construire une image Docker durant ma pipeline qui s'exécute elle-même dans un conteneur.


État des lieux

Jusqu'à présent j'utilisais les shared runners de GitLab CI avec l'exécuteur Docker, en DinD avec une image créée par mes soins qui embarquait Docker Compose en plus: aymdev/dind-compose.

L'intérêt étant de démarrer une stack Docker Compose pour certains jobs (tests fonctionnels notamment, qui ont besoin d'une base de données en plus de l'application), et parce que l'application (ce site) est déployée dans un cluster Docker Swarm, donc autant tester directement avec l'image qui sera déployée.

Le problème c'est que mon image n'était qu'un essai que je ne maintiens pas, qui n'a pas beaucoup d'intérêt, et que démarrer toute la stack juste pour mes tests fonctionnels est un peu lourd (4 conteneurs au total pour 2 utilisés). Et mes fichiers docker-compose.yml étaient multipliés, ce qui n'arrangeait pas la maintenabilité du code.


Premier essai, premier échec

Lors de l'installation de GitLab Runner vient le choix de l'executor. Ayant la main sur la machine, j'ai pensé que le shell executor serait une bonne idée, et pour plusieurs raisons. Celui-ci exécute simplement les jobs sur la machine du runner (l'hôte), donc:

  • Il suffit d'installer Docker et Docker Compose pour utiliser leurs commandes en CI
  • les images sont enregistrées dans le registre de l'hôte donc on peut bénéficier du cache de construction
  • si besoin de SSH ce peut être directement configuré sur l'hôte sans avoir besoin de passer de clé privée via une variable de GitLab CI

Mais c'était sans compter sur 1 problème: la concurrence.
En effet, tous les jobs se partagent le même environnement, donc si plusieurs d'entre eux s'exécutent en parallèle ils risquent de se perturber. Il faut faire attention à:

  • nommer ses conteneurs de manière unique
  • ne pas arrêter des conteneurs pour faire un « clean up » à la fin d'un job au risque d'arrêter ceux d'un autre job
  • ne pas occuper certains ports sur l'hôte qui bloquerait le démarrage d'autres conteneurs
  • ...

Et c'est sans compter sur la gestion de l'espace de stockage (images qui s'accumulent), la gestion des conteneurs laissés démarrés parce que la pipeline a planté, etc.

Bref, j'ai réinitialisé mon instance.


Retour à l'exécuteur Docker

L'instance utilisée tourne sous Debian 10. Je pense qu'il est préférable d'utiliser une machine optimisée au niveau de la mémoire et de délaisser l'espace disque, surtout si votre budget est limité.

Installation de GitLab Runner

Connecté en SSH sur l'instance:

curl -L https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh | sudo bash
export GITLAB_RUNNER_DISABLE_SKEL=true; sudo -E apt-get install gitlab-runner

Source: Install GitLab Runner using the official GitLab repositories

Puisque j'utilise Debian, j'ai également suivi la recommandation de l'APT pinning.

Installation de Docker

Et on installera que Docker, pas Docker Compose:

sudo apt-get update
sudo apt-get install apt-transport-https ca-certificates curl gnupg-agent software-properties-common
curl -fsSL https://download.docker.com/linux/debian/gpg | sudo apt-key add -
sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/debian $(lsb_release -cs) stable"
sudo apt-get update
sudo apt-get install docker-ce docker-ce-cli containerd.io

Source: Install Docker Engine on Debian

Enregistrer le runner

Avant d'enregistrer le runner, on a besoin de récupérer un token afin de lier notre projet ou groupe de projets au runner. Pour trouver celui-ci, depuis l'interface de GitLab, aller dans Settings > CI/CD et ouvrir la section Runners.

Note: depuis cette section, pour un projet (pas au niveau d'un groupe), il est possible de désactiver les shared runners pour forcer l'utilisation de vos propres runners.

Une fois copié, on peut lancer l'enregistrement du runner. La commande suivante sera interactive:

sudo gitlab-runner register

Pour l'URL du coordinateur, n'utilisant pas ma propre instance GitLab, c'est donc https://gitlab.com.
On passe ensuite le token, une description (sera affichée dans l'interface de CI/CD), des tags (facultatif), et enfin, l'exécuteur docker.
Une dernière question demande une image par défaut, on choisira docker:19.03.13.

Configuration du runner pour utiliser DinD

La documentation de GitLab propose 3 manières de permettre Docker in Docker.

Docker socket binding

La technique du Docker socket binding consiste à monter /var/run/docker.sock en volume entre hôte et conteneurs.
Bien qu'elle ait l'air pratique, elle revient à ce que tous les conteneurs partagent le même daemon Docker. Cela signifie que l'on retrouverait une bonne partie des problèmes de concurrence du shell executor (noms uniques, etc).

TLS désactivé

Solution viable mais à utiliser lorsque l'on ne peut pas faire autrement.

GitLab: « Par exemple, si vous n'avez aucun contrôle sur la configuration du GitLab Runner que vous utilisez. »

On a justement la main sur la configuration.

TLS activé

C'est l'option recommandée, pour des raisons de sécurité. Ouvrir le fichier de configuration du runner: /etc/gitlab-runner/config.toml:

concurrent = 1
check_interval = 0

[session_server]
  session_timeout = 1800

[[runners]]
  name = "GitLab Runner"
  url = "https://gitlab.com/"
  token = "super-secret-token"
  executor = "docker"
  [runners.custom_build_dir]
  [runners.cache]
    [runners.cache.s3]
    [runners.cache.gcs]
    [runners.cache.azure]
  [runners.docker]
    tls_verify = false
    image = "docker:19.03"
    disable_entrypoint_overwrite = false
    oom_kill_disable = false
    disable_cache = false
    volumes = ["/cache"]
    shm_size = 0

Dans la section [runners.docker], rajouter privileged = true, le mode d'exécution privilégié est nécessaire pour du DinD. Dans la même section, en ce qui concerne TLS, rajouter le volume "/certs/client".

Pour éviter que le runner n'exécute qu'un seul job à la fois, modifier la clé concurrent en tout début de fichier.

Le fichier devrait ressembler à ça (raccourci):

concurrent = 4
...

[[runners]]
  ...
  [runners.docker]
    ...
	privileged = true
    volumes = ["/cache", "/certs/client"]
    ...

Il ne devrait pas être nécessaire de redémarrer le runner mais vous pouvez le faire avec les commandes suivantes:

gitlab-runner restart
sudo service gitlab-runner restart

Pipeline

Côté runner, c'est terminé !
Maintenant il faut s'occuper de notre fichier .gitlab-ci.yml.

Faire fonctioner le DinD

Si besoin, préciser l'image à utiliser par défaut:

image: docker:19.03.13

L'intérêt est de pouvoir changer de version sans avoir à changer la version par défaut dans la configuration du runner.

Dans les variables par défaut, rajouter une variable pour TLS:

variables:
    DOCKER_TLS_CERTDIR: "/certs"

Et enfin, pour utiliser Docker in Docker, ajouter un service docker:

services:
    -   name: docker:19.03.13-dind
        alias: docker

Utiliser la même image Docker dans une pipeline

Pour éviter de devoir reconstruire son image Docker à chaque job, on peut la construire dans un 1er job, l'envoyer dans le registre d'images fourni par GitLab, et la récupérer dans les jobs suivants.

Le nom de cette image devra être unique par pipeline. Puisqu'il sera utilisé dans chaque job, on rajoute une autre variable en utilisant des variables d'environnement prédéfinies:

variables:
	...
    DOCKER_CI_IMAGE: "$CI_REGISTRY_IMAGE:ci-$CI_COMMIT_SHORT_SHA"

Il sera nécessaire de se connecter au registre à chaque job, autant créer une ancre YAML à référencer dans la clé before_script de chaque job:

.docker-login: &docker-login
    - echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin $CI_REGISTRY

Un premier job consistera à créer l'image de la pipeline:

docker-build:
    before_script:
        - *docker-login
    script:
        - docker build --pull -t "$DOCKER_CI_IMAGE" -f ./Dockerfile .
    after_script:
        - docker push $DOCKER_CI_IMAGE

Les suivants auront seulement besoin de se connecter au registre et l'image sera téléchargée sur demande:

example-job:
    before_script:
        - *docker-login
    script:
        - docker run -d $DOCKER_CI_IMAGE

Remplacer Docker Compose

Pour certains jobs, il sera suffisant de démarrer 1 conteneur de l'application à tester et d'y exécuter quelques commandes à l'aide de docker exec.
Pour d'autres, ça peut être plus compliqué: tests fonctionnels nécessitant une base de données.

« Alors on ajoute un service au job concerné avec la clé services appropriée ... »

Justement non, car ce service sera accessible pour le conteneur du job, pas pour celui de notre application qui se trouve à l'interieur du premier. Et les manipulations pour réussir à contacter le service sont plus complexes que ce que j'ai à proposer.

Docker Compose permet de démarrer plusieurs conteneurs facilement, mais il ne propose pas de fonctionnalité en plus de Docker seul. On peut donc en quelques commandes recréer un environnement de test idéal.
Petit exemple:

test-suite:
    variables:
        POSTGRES_PASSWORD: postgres
        DATABASE_URL: "pgsql://postgres:postgres@postgres/postgres"
    before_script:
        - *docker-login
        - docker network create app-net
        - docker run -d --network app-net --name postgres -e POSTGRES_PASSWORD postgres:11.7-alpine
        - docker run -d --network app-net --name app -e DATABASE_URL $DOCKER_CI_IMAGE
        - docker exec app ./path/to/database-migrations
    script:
        - docker exec app ./path/to/your/tests

Conclusion

Si vous avez lu jusqu'ici, merci !

Bon, l'utilisation de Docker in Docker fonctionne bien, mais a aussi ses désavantages, notamment au niveau du cache de construction d'images qui demande des manipulations supplémentaires.

Je n'ai pas abordé la structure complète d'une pipeline ou comment réduire sa durée d'exécution mais ce sera probablement le sujet d'un prochain article. J'espère que ce premier article vous aura plu, pour ma part je me suis bien amusé !

Commentaires: 0

Invasion robot en provenance de robohash.org