Utilisation de cache avec GitLab CI en Docker-in-Docker

Publié le 02/10/2020 à 22:32. Mise à jour le 04/10/2020 à 15:16.

Une caractéristique importante dans les pipelines: le temps d'exécution ! Et l'utilisation du cache en CI ça aide beaucoup.

Utilisation de cache avec GitLab CI en Docker-in-Docker

Photo par Marcin Jozwiak sur Unsplash
Icone créée par Becris de flaticon


Dans l'article précédent on a pu voir quelques subtilités de la construction d'images Docker sur GitLab CI. Ce que nous n'avons pas vu, c'est comment optimiser le temps d'exécution à l'aide de différents caches.

Bonnes pratiques Docker

Avant d'attaquer les optimisations au niveau de GitLab CI, il faut optimiser ses images. On retrouve ces conseils jusque dans la doc de Docker donc je vais essayer de résumer certains points.

Ordre des instructions d'un Dockerfile

Quand on construit une image, elle est constituée de plusieurs couches (« layers »): on rajoute une couche à chaque instruction.
Si on relance la construction de la même image sans modifier aucun fichier, alors Docker utilisera les couches préexistantes plutôt que de réexécuter l'instruction. Si pour une certaine instruction, un fichier a été modifié, alors le cache sera invalidé pour cette instruction et les suivantes.

Petit extrait de cours pour illustrer:
La 2e instruction a été modifiée, le cache n'est donc pas utilisé pour les instructions 2 & 3.

En changeant l'ordre des instructions, j'optimise les chances d'utilisation du cache en plaçant les instructions les plus susceptible de changer en dernier:

C'est le point le plus important à retenir: une image est constituée de plusieurs couches, et on peut se baser sur une version précédente pour accélérer la construction.

Autres conseils

Le 1er point est celui qui nous intéresse mais bon puisqu'on est là... Pour optimiser de différentes manières vos images, vous pouvez également:

Utiliser le cache des couches d'images Docker en CI

Le problème avec la construction d'images en CI, c'est que si on utilise Docker-in-Docker, on démarre chaque job avec une instance toute fraiche de Docker, dont le registre local est vide. Et si on a pas d'image sur laquelle se baser: pas de cache.

Pas de panique, on peut régler ça très facilement !

L'option --cache-from

Depuis Docker 1.13 on peut utiliser l'option --cache-from avec la commande build, afin d'indiquer depuis quelle image utiliser le cache:

docker build --cache-from image:old -t image:new -f ./Dockerfile .

Il suffit donc de se connecter au registre de son projet GitLab et de se baser sur l'image la plus récente:

build:
    before_script:
        - echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin $CI_REGISTRY
        - docker pull "$CI_REGISTRY_IMAGE:latest"
    script:
        - docker build --cache-from "$CI_REGISTRY_IMAGE:latest" -t "$CI_REGISTRY_IMAGE:new-tag" -f ./Dockerfile .

Et pour les tags Git ?

Si comme moi vous avez un Dockerfile spécifique à la version de production, ça ne vous avancera pas d'utiliser le cache à partir de l'image de développement: il sera toujours invalidé à partir d'une instruction spécifique.
En revanche si vous maintenez bien un CHANGELOG sous ce format, et/ou que vos tags Git sont également vos tags Docker, vous pouvez récupérer la précédente version et utiliser le cache à partir de cette version de votre image.

Exemple avec la récupération de la version précédente depuis le CHANGELOG.md:

release:
    before_script:
        - echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin $CI_REGISTRY
    script:
        - export PREVIOUS_VERSION=$(perl -lne 'print "v${1}" if /^##\s\[(\d\.\d\.\d)\]\s-\s\d{4}(?:-\d{2}){2}\s*$/' CHANGELOG.md | sed -n '2 p')
        - docker build --cache-from "$CI_REGISTRY_IMAGE:$PREVIOUS_VERSION" -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_TAG" -f ./prod.Dockerfile .

« Pourquoi à partir du changelog et pas simplement avec git tag ? »
Dans le changelog, je peux indiquer si une version est instable et donc elle ne sera pas utilisée. Ceci dit, ça ne fait pas partie de la spécification de ce format de changelog.

Mettre en cache les dépendances

Pour la majorité des projets, le « Docker layer caching » peut être largement suffisant pour optimiser le temps de build. Mais on peut essayer d'aller encore plus loin.

Principe du cache en CI/CD

Le cache en CI/CD permet de conserver des dossiers ou fichiers à travers les pipelines. En général on peut attribuer un nom à chaque élément du cache pour le partager entre les jobs de différentes manières.
Ce cache évite la réinstallation des dépendances à chaque build.

Le problème ? On construit une image Docker, les dépendances sont installées dans un conteneur.
On ne peut donc pas demander de mettre en cache un dossier de dépendances si celui-ci n'existe pas dans l'espace de travail d'un job.

Mettre en cache lors de la construction d'une image

Pour commencer, il est nécessaire de retirer les dossiers à mettre en cache d'éventuels fichiers .dockerignore. En effet, les dépendances seront toujours installées depuis un conteneur mais elles seront restituées par le GitLab Runner dans l'espace de travail du job. Le but est d'envoyer la version en cache en build context.

On indique dans le job quels dossiers mettre en cache avec une clé permettant de partager le cache par branche et stage.
Ensuite, on crée un conteneur sans le démarrer avec la commande docker create et on copie les dossiers de dépendances depuis le conteneur vers l'hôte avec docker cp:

build:
    stage: build
    cache:
        key: "$CI_JOB_STAGE-$CI_COMMIT_REF_SLUG"
        paths:
            - vendor/
            - node_modules/
    before_script:
        - echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin $CI_REGISTRY
        - docker pull "$CI_REGISTRY_IMAGE:latest"
    script:
        - docker build --cache-from "$CI_REGISTRY_IMAGE:latest" -t "$CI_REGISTRY_IMAGE:new-tag" -f ./Dockerfile .
    after_script:
        - docker create --name app "$CI_REGISTRY_IMAGE:new-tag"
        - rm -rf ./vendor
        - docker cp app:/var/www/html/vendor/ ./vendor
        - rm -rf ./node_modules
        - docker cp app:/var/www/html/node_modules/ ./node_modules

« Et pourquoi la suppression des dossiers de dépendances avant la copie ? »
Pour éviter que les anciennes dépendances soient mélangées aux nouvelles au risque de garder en cache des dépendances qui ne seraient plus utilisées, ce qui allourdirait inutilement le cache et les images construites.

Si vous avez besoin de mettre en cache des dossiers lors d'exécution de tests, la solution est bien plus simple: utilisez des volumes !

Chérie, j'ai allourdi le cache ...

En testant tout ça avant l'écriture, j'avais des images qui doublaient presque en taille à chaque build. Une erreur de syntaxe me faisait imbriquer les dossiers de dépendances dans leur ancienne version.

Donc si comme moi vous avez tendance à tout détruire, prenez de bonnes habitudes dès le début: versionnez vos clés de cache !


Je vais bien, merci. Et vous ?

Dans mon cas, je me suis aperçu de l'erreur après quelques builds, donc le cache était corrompu car il contenait déjà la « dépendance-ception » que j'avais causé. Quoi que je change dans mon code, le cache aurait été renvoyé et réutilisé. Il faut donc l'invalider ! Et comment ? En changeant la clé !

Donc toutes vos clés de cache pourraient être suffixées de -v1, puis -v2 en cas de problème.

Bonus: partage d'image Docker entre jobs

L'idéal dans sa pipeline c'est de ne pas avoir à reconstruire son image Docker à chaque job. On construit donc une image avec un tag unique (utilisant le hash du commit par exemple).

Je crée toujours une variable pour le nom de l'image à utiliser dans une pipeline:

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

La 1e stratégie consiste à utiliser le registre d'images de GitLab CI, on docker push après la construction, et on docker pull si besoin aux jobs suivants.

Mais une 2e méthode peut vous intéresser, qui évitera de remplir votre registre inutilement avec une multitude de tags de CI.

L'alternative save / load

En alternative à la méthode « push / pull », on peut enregistrer une image Docker en archive .tar et la transmettre d'un job à un autre en déclarant l'archive comme un artéfact.

Dans chaque job, on récupère automatiquement les artéfacts des stages précédents.

Lors du build, on créé l'archive avec docker save et gzip:

build:
    stage: build
	artifacts:
	    paths:
		    - app.tar.gz
    cache: ...
    before_script: ...
    script:
        - docker build --cache-from "$CI_REGISTRY_IMAGE:latest" -t "$DOCKER_CI_IMAGE" -f ./Dockerfile .
    after_script:
        - ...
		- docker save $DOCKER_CI_IMAGE | gzip > app.tar.gz

Et lors des autres jobs, il n'est pas nécessaire de se connecter à un registre, il suffit juste de charger l'image depuis l'archive récupérée en tant qu'artefact:

test:
    stage: test
	before_script:
	    - docker load --input app.tar.gz
	script:
	    - docker run -d --name app $DOCKER_CI_IMAGE
		- ...

Conclusion

Pour ma part j'utilise bien la technique « push / pull », l'autre ne m'ayant pas donné de résultats satisfaisants, mais ça dépend probablement des projets. Ces petites techniques m'ont permis de gagner au minimum 1 minute dans mes pipelines.
J'espère en tous cas que ça vous aura fait découvrir des fonctionalités de GitLab CI ou de Docker.
Je pense qu'on parlera prochainement des artéfacts de GitLab CI avec les outils de tests et d'analyse statique en PHP.

Commentaires: 1

kordeviant 01/06/2022 - 09:31
I did the save load alternative but time of my pipeline didn't improve so much, using a local cache like docker on host has so much more effect.

Invasion robot en provenance de robohash.org