Github Actions et Docker

Posté le 22-06-2024 par Aliaume Lopez – lecture en 9 minutes (≈ 2150 mots)

J’essaie de plus en plus d’utiliser des techniques d’intégration continue et de déploiement continu pour mes projets, même s’ils sont principalement des projets de type “papier de recherche”, c’est-à-dire, utilisant LaTeX. Cela répond à plusieurs problématiques très concrètes :

  1. Qui n’a pas déjà eu un mail d’un collègue disant “le document ne compile pas”, alors que tout fonctionnait parfaitement sur notre machine ?
  2. Qui n’a pas déjà eu une mauvaise surprise en poussant un document sur arXiv et en obtenant une/des erreurs de compilation et/ou un rendu complètement différent ?

Cela devient d’autant plus important lorsque vous avez vos propres petits scripts pour automatiser certaines tâches. Typiquement, pour générer des documents PDFs à partir d’images en SVG via Inkscape, ou un utilitaire pour aplatir les fichiers sources LaTeX avant de les envoyer à un éditeur en utilisant latexpand. Dans mon cas, j’essaie d’utiliser de plus en plus du markdown et le convertir en LaTeX avec l’aide de pandoc, pour des raisons que je ne vais pas détailler ici. Comme pandoc repose sur la notion de “filtre” pour effectuer des transformations de documents, ceux-ci étant (souvent) écrits en Python, il est alors nécessaire de gérer un environnement virtuel Python, les versions des dépendances, entre les paquets python, la version de pandoc, celle de LaTeX, etc.

1.

On pensera par exemple à la plateforme IPOL et à la reproductibilité des résultats scientifiques pour le traitement d’images.

Même si ce n’est pas idéal pour tout un tas de raisons éthiques, j’ai commencé à utiliser Github Actions pour automatiser les tâches de compilation de mes documents. Le maître mot ici est “reproductibilité”.11

2.

Attention, cette action ne fonctionne certainement pas. En pratique, pip3 utilisé depuis la ligne de commande nécessite de créer un environnement virtuel pour ne pas casser les paquets systèmes. Vu que tout cela est déjà dans une machine virtuelle, créer un tel environnement n’a pas de sens et il faudrait plutôt utiliser l’argument --break-system-packages de pip3.

L’exemple prototypique d’une telle action est la suivante22 :

name: Build LaTeX document
on:
  push:
    branches:
      - main
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - name: Install LaTeX
        run: sudo apt-get install texlive-full
      - name: Install inkscape
        run: sudo apt-get install inkscape
      - name: Install python3
        run: sudo apt-get install python3 python3-pip
      - name: Install required packages
        run: pip3 install -r requirements.txt
      - name: Build LaTeX document
        run: pdflatex -interaction=nonstopmode -halt-on-error document.tex

Cette méthode est intéressante, car on peut automatiser la création de pdfs qui seront visibles sur la page du projet, que l’on peut vraiment faire de la gestion sémantique des versions, mais pose par ailleurs plusieurs problèmes :

  1. Il est extrêmement fastidieux de faire la liste exhaustive des dépendances, surtout quand on utilise un système d’exploitation qui n’est pas le ubuntu-latest.
  2. Tester le bon fonctionnement de l’action en local est compliqué.
  3. Utiliser du Yaml comme un langage de programmation est une hérésie, et il faudra changer ce fichier de configuration lors d’une migration sur une autre forge.

Pour pallier ces problèmes, j’ai essayé deux solutions assez différentes. La première est d’utiliser NixOs pour gérer les dépendances, un choix assez raisonnable quand on sait que leur argument principal est la reproductibilité

Nix is a tool that takes a unique approach to package management and system configuration. Learn how to make reproducible, declarative and reliable systems.

La seconde est d’utiliser Docker pour créer une image contenant toutes les dépendances nécessaires à la compilation du document.

L’approche NixOs

Afin de répondre aux problématiques de gestion des versions de paquets et de reproductibilité, Eelco Dolstra a créé le gestionnaire de paquets Nix en 2003. Au cœur de son fonctionnement il y a la notion de répertoires uniques et immuables : pour chaque logiciel un répertoire est créé et son contenu ne changera jamais. L’identification du logiciel s’effectue à l’aide d’un hash cryptographique qui prend en compte toutes les dépendances du logiciel, y compris les autres logiciels gérés par Nix. Cela permet de faire coexister des “arbres de dépendances” très différents sur un même système en utilisant des alias bien sentis.

La mise en pratique de cette idée s’avère un peu plus complexe, car le système repose sur un langage de programmation spécifique pour décrire les dépendances d’un projet (et permettre de calculer les identités associées). Je trouve ce langage tout simplement hideux. Cependant, pour une utilisation “simple”, cela restera relativement lisible.

Pour déclarer un environnement de développement adapté à notre projet, il suffit de créer un fichier shell.nix dans le dossier racine du projet dans ce langage turing complet, paresseux, avec un typage dynamique, une syntaxe incompréhensible et très peu de documentation.

{ pkgs ? import <nixpkgs> {} }:
pkgs.mkShell {
  buildInputs = with pkgs; [
    texlive.combined.scheme-full
    inkscape
    python3
  ];
}
3.

la première est pour installer Nix, la seconde pour utiliser un cache de dépendances afin de ne pas recompiler à chaque lancement de l’action.

Une fois ce code écrit, on peut simplement utiliser la commande nix-shell pour entrer dans un environnement où toutes les dépendances sont installées. Cette méthode a été décrite dans un billet de blog de Luc Perkins pour Determinate Systems. Cela permet de tester en local le processus de compilation et simplifie le fichier de configuration de l’action, où les installations sont remplacées par ces trois commandes33 :

- name: Install Nix
uses: DeterminateSystems/nix-installer-action@main
- uses: DeterminateSystems/magic-nix-cache-action@main  
- name: Build LaTeX document
run: nix develop --command "pdflatex -interaction=nonstopmode -halt-on-error document.tex"

Notons que pour simplifier encore plus le fichier de configuration, il est de bon goût de créer un fichier Makefile dans la syntaxe de GNU Make, qui permet de simplement écrire make all, make tests, make clean, plutôt que des commandes longues et compliquées. Cela a pour autre avantage de permettre une meilleure découvrabilité du système de construction : sur un grand nombre de plateformes, écrire make suivi de la touche TAB permet d’afficher toutes les commandes disponibles.

Il y a tout de même plusieurs inconvénients à cette méthode :

  1. En réalité, les exécutables présents sur votre machine sont toujours accessibles depuis l’environnement nix-shell. Cela veut dire qu’il est toujours possible de découvrir des dépendances mal spécifiées bien trop tard.
  2. Malgré la présence d’un cache, le temps de compilation reste assez long.

L’approche Docker

4.

Je n’ai aucune référence pour étayer cette affirmation.

Introduits en 2006 sous le nom de “conteneurs de processus”, les “Control Groups” CGROUPS ont été intégrés à la version 2.6.24 du noyau Linux. Cette nouvelle fonctionnalité visait à isoler des groupes de processus en leur limitant l’accès à certaines ressources (CPU, mémoire, réseau, etc.). En utilisant cette technologie, une forme relativement légère de virtualisation a été créée sous le nom de LXC pour “Linux Containers”. L’intérêt est un coût en ressources bien plus faible que les machines virtuelles traditionnelles, tout en offrant une isolation suffisante pour la plupart des cas d’utilisation.44

5.

Il suffit de regarder sur le site de Docker Hub pour voir le nombre d’images disponibles, toutes subtilement différentes.

Basé sur LXC et d’autres technologies (libvirt, ou systemd-nspawn), Docker a été crée en 2013 par Solomon Hykes et est devenu un outil incontournable sur “le cloud” (comprendre, vendre du temps de machine à des entreprises, sans avoir à maintenir un parc de machines virtuelles). Le principal atout a été de standardiser un format d’image afin de permettre l’échange et la centralisation de conteneurs “prêts à l’emploi”.55

Pour créer une image Docker, il suffit de créer un fichier Dockefile dans le dossier racine du projet. Par exemple, pour une image contenant les dépendances nécessaires à la compilation de documents LaTeX, plus quelques logiciels utiles comme pandoc, make ou Inkscape.

FROM pandoc/latex:latest
RUN apk add --no-cache make
RUN apk add --no-cache inkscape

Fort de ce nouveau document (dont la syntaxe est relativement plus lisible que celle de Nix), il est possible de créer une image Docker en utilisant une commande dans le style de docker build -t my-image .. Une fois l’image créée, il est possible de lancer un conteneur à partir de cette image en utilisant la commande docker run -it my-image. À des fins de test, il est souvent utile de monter un volume pour que les fichiers du projet soient accessibles, la commande ressemble alors à docker run -it -v $(pwd):/work my-image. L’objectif ici n’est pas de fournir un tutoriel complet au logiciel Docker, je vais donc m’arrêter là pour les commandes de base.

À ce stade, on obtient un premier avantage par rapport à Nix : l’image ne contient pas les logiciels de la machine hôte, et on se rend donc directement compte des problèmes potentiels.

La seule question restante est alors : “Mais comment utiliser cette image dans une action Github Actions ?” J’ai cru dans un premier temps qu’il faudrait passer par le Docker Hub, ce qui nécessiterait d’avoir des identifiants, une gestion des versions, etc. De plus, il est un peu étrange de créer (et de publier) une image qui n’a de sens que pour un seul projet.

J’ai découvert par hasard qu’il était possible de s’appuyer sur le Github Container Registry, qui pour stocker des images Docker directement sur le dépôt Github. Un bonus de cette approche est que ces images sont ajoutées comme “artéfacts” du projet, ce qui veut dire qu’une personne peut venir la télécharger pour se servir directement de l’environnement de développement du projet.

Pour automatiser la création de l’image, il suffit de créer un fichier de configuration adéquat qui peut ressembler à celui ci-dessous :

name: Build Docker Image
on:
  workflow_dispatch:

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  build-and-push-image:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4
      - name: Extract metadata (tags, labels) for Docker
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
      - name: Log in to the Container Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - name: Build and push Docker image
        uses: docker/build-push-action@v5
        with:
          context: .
          file: Dockerfile
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}

La partie importante ici est de ne pas créer l’image à chaque lancement ou de manière automatique, mais de proposer un bouton pour le faire (c’est la ligne workflow_dispatch).

6.

C’est-à-dire qui ne contient aucune constante relative au dépôt écrite en dur.

L’utilisation de l’image dans une action est alors très simple, il suffit de spécifier que l’action doit s’effectuer dans un conteneur en spécifiant l’image à utiliser. Avec un peu de templating, on arrive même à écrire un fichier “prêt à copier-coller”66 qui ressemble à ceci :

. . .
jobs:
  build:
    runs-on: ubuntu-latest
    container:
      image: ghcr.io/${{ github.actor }}/${{ github.repository }}:main
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4
      - name: Builds the tar.gz archive
        run: make all
      - name: Upload artifacts
        uses: actions/upload-artifact@v4
        with:
          name: artifacts.tar.gz
          path: artifacts.tar.gz

Conclusion

En pratique, j’ai trouvé que l’approche Docker était plus simple à mettre en place, plus rapide et plus efficace. L’image est mise en cache et il est extrêmement rare d’avoir à la recréer (sauf changement majeur dans le projet). Par ailleurs, il est très facile de tester en local comment la compilation s’effectue dans le conteneur : l’exemple typique du moment est l’incompatibilité entre biber et biblatex en fonction du mode d’installation…

7.

Et d’ailleurs, pourquoi installer Nix plutôt que son concurrent Guix ?

L’approche Nix garde l’avantage d’être agnostique au système d’exploitation utilisé, et est “sans frictions” : il suffit de taper nix-shell et tout est automatisé. Il est possible d’utiliser Nix pour générer des images Docker “minimales”. En pratique cependant, il est assez difficile de savoir ce dont l’action lancée par Github Actions aura besoin (git? ruby? python?). De plus, pour les utilisateurs qui ne veulent pas installer un autre gestionnaire de paquets,77 il reste nécessaire de bien lister les dépendances et fournir une idée approximative de leur méthode d’installation (le contraire de Nix donc).

En conclusion, je garde sous le coude un fichier shell.nix, un Dockerfile et une partie du README dédiée à la gestion des dépendances, tous étant en réalité complémentaires.


  1. On pensera par exemple à la plateforme IPOL et à la reproductibilité des résultats scientifiques pour le traitement d’images.↩︎

  2. Attention, cette action ne fonctionne certainement pas. En pratique, pip3 utilisé depuis la ligne de commande nécessite de créer un environnement virtuel pour ne pas casser les paquets systèmes. Vu que tout cela est déjà dans une machine virtuelle, créer un tel environnement n’a pas de sens et il faudrait plutôt utiliser l’argument --break-system-packages de pip3.↩︎

  3. la première est pour installer Nix, la seconde pour utiliser un cache de dépendances afin de ne pas recompiler à chaque lancement de l’action.↩︎

  4. Je n’ai aucune référence pour étayer cette affirmation.↩︎

  5. Il suffit de regarder sur le site de Docker Hub pour voir le nombre d’images disponibles, toutes subtilement différentes.↩︎

  6. C’est-à-dire qui ne contient aucune constante relative au dépôt écrite en dur.↩︎

  7. Et d’ailleurs, pourquoi installer Nix plutôt que son concurrent Guix ?↩︎