DevOps avec Dhawos

Cloud, infrastructure et programmation

Utiliser Nix ou Docker pour des environnements de dev reproductibles

Introduction

Disposer d’une CI/CD efficace et robuste est l’une des clés pour développer des services de qualité. Cependant, mettre en place un tel système n’est pas toujours simple, et le maintenir dans le temps l’est encore moins. L’une des principales raisons en est la difficulté à obtenir un environnement d’exécution similaire entre le système de CI et les postes locaux des développeurs.

Malheureusement, ce type de système est parfois sous-développé, car il n’est pas toujours perçu comme créateur de valeur. Pourtant, sans lui, il est impossible de livrer un service dans de bonnes conditions et à un rythme soutenu.

À mon sens, l’essor de GitHub Actions est un symptôme de ces problèmes. En y réfléchissant, la promesse des GitHub Actions est de passer le moins de temps possible sur sa CI en utilisant au maximum les actions du marketplace. La promesse est alléchante, et il est vrai que le catalogue est bien fourni. Mais dès que l’on creuse un peu, on se rend compte que ce paradigme peut rapidement mener à plusieurs problèmes.

Notamment :

Dans la suite de cet article, nous allons tenter de mettre en place de bonnes pratiques pour limiter ces problèmes tout en profitant des avantages de GitHub Actions.


Le scénario

Fixons-nous un objectif en partant d’une application simple : un serveur web en Go.

Pour éviter de nous limiter à un simple “hello-world”, nous allons y ajouter une bibliothèque tierce : templ pour le rendu HTML. Si vous ne connaissez pas templ, pas d’inquiétude, ce n’est pas le cœur du sujet. Retenez simplement que cet outil nécessite d’exécuter une commande pour générer du code avant de compiler notre application.

À partir de là, nous souhaitons être capables de :

Mais nous allons nous imposer plusieurs contraintes pour garder une CI/CD maintenable :

C’est parti !


Linting du code

Commençons par cet aspect, qui sera le plus simple. Si vous êtes un peu familier avec GitHub Actions, vous savez probablement qu’il est possible de le faire très simplement. Nous allons utiliser l’outil golangci-lint, un excellent outil qui permet de vérifier que notre code respecte un certain nombre de standards, comme le fait de ne pas ignorer d’erreurs.

Il faudra simplement veiller à bien générer notre code via templ generate avant.

on:
  push:
    branches:
      - main

jobs:
  codegen:
    name: "Ensure code is generated"
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - uses: actions/setup-go@v6
        with:
          go-version-file: "go.mod"
      - name: Setup templ
        run: go install github.com/a-h/templ/cmd/templ@latest
      - name: "Ensure code is generated"
        run: scripts/codegen.sh
  lint:
    name: "Lint code"
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - uses: actions/setup-go@v6
        with:
          go-version-file: "go.mod"
      - name: golangci-lint
        uses: golangci/golangci-lint-action@v9

La lune de miel avec GitHub Actions bat son plein : le code sera vérifié à chaque commit avec seulement 15 lignes de YAML. Tout semble parfait…

Sauf que non. Plusieurs problèmes persistent :


Améliorons un peu tout cela

Nous pouvons déjà faire mieux sans introduire de nouvel outil. Pour cela, il faut “pin” nos actions à des commits précis. C’est d’ailleurs une recommandation de sécurité faite par GitHub Actions, mais qui me semble peu souvent mise en œuvre en pratique.

La référence est ici : “Pin actions to a full-length commit SHA”.

Voici ce que cela donne après cette modification :

name: Run workflow with pinned dependencies
on:
  push:

jobs:
  codegen:
    name: "Ensure code is generated"
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
      - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
        with:
          go-version-file: "go.mod"
      - name: Setup templ
        run: go install github.com/a-h/templ/cmd/templ@latest
      - name: "Ensure code is generated"
        run: scripts/codegen.sh
  lint:
    name: "Lint code"
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
      - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
        with:
          go-version-file: "go.mod"
      - uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0

Ici, nous nous protégeons contre un vecteur d’attaque majeur. Cependant, il faut noter quelques points :

Profitons-en pour ajouter les tests. Notre workflow devrait maintenant ressembler à ceci :

name: Run workflow with pinned dependencies
on:
  push:

jobs:
  codegen:
    name: "Ensure code is generated"
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
      - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
        with:
          go-version-file: "go.mod"
      - name: Setup templ
        run: go install github.com/a-h/templ/cmd/templ@latest
      - name: "Ensure code is generated"
        run: scripts/codegen.sh
  lint:
    name: "Lint code"
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
      - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
        with:
          go-version-file: "go.mod"
      - uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0
  test:
    name: "Test code"
    runs-on: ubuntu-latest
    needs:
      - codegen
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
      - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
        with:
          go-version-file: "go.mod"
      - name: "Tests"
        shell: bash
        run: go test ./...

Comme on peut le voir, nous allons télécharger la même version de Go trois fois de suite, ce qui est dommage. De plus, il est difficile de tester cette CI en local dans les mêmes conditions. Les deux principaux problèmes de cette approche sont donc :

Si ces deux points ne vous posent pas de problème, il est probable que vous n’ayez pas besoin des pratiques que nous allons voir ensuite. Si cela fonctionne pour vous et que vous n’avez pas de difficulté à maintenir votre CI, mieux vaut ne pas la complexifier.

Ce n’est pas mon cas. Ayant travaillé à de nombreuses reprises sur des CI, je pense que pouvoir modifier et tester les scripts en local est une aide précieuse qui vous fera gagner beaucoup de temps. Cela permettra également de partager le même environnement de développement avec tous les contributeurs, alors pourquoi s’en priver ?


La quête de la reproductibilité

Si nous voulons pouvoir tester notre CI en local et éviter de retélécharger tout ou partie de notre toolchain, nous devons résoudre un problème de taille : mettre en place un environnement reproductible, quelle que soit la machine qui l’exécute.

Jusqu’à présent, ma réponse à cette question était immédiatement Docker, mais j’ai récemment tenté d’apprivoiser Nix, qui présente une courbe d’apprentissage un peu plus abrupte. Ces deux technologies permettent de traiter ce sujet de manière totalement différente, chacune avec ses avantages et ses inconvénients.

Plutôt que de nous lancer dans une comparaison abstraite, essayons d’adapter notre CI grâce à ces deux outils.


Docker

Docker est désormais très utilisé, je ne vais donc pas revenir en détail sur ce que c’est et dans quels autres cas cela peut être utile. Rappelons simplement qu’une image Docker permet de produire un filesystem minimaliste qui permet d’exécuter des programmes avec uniquement leurs dépendances.

Dans notre cas, voici à quoi pourrait ressembler une telle image :

FROM golang@sha256:fcdb3e42c5544e9682a635771eac76a698b66de79b1b50ec5b9ce5c5f14ad775 # golang:1.26.2
ARG SCALEWAY_CLI_VERSION="2.54.0"
ARG GOLANGCI_LINT_VERSION="2.11.4"

RUN apt-get update && apt-get install -y wget

RUN wget "https://github.com/scaleway/scaleway-cli/releases/download/v${SCALEWAY_CLI_VERSION}/scaleway-cli_${SCALEWAY_CLI_VERSION}_linux_amd64" -O /tmp/scaleway-cli && \
    chmod +x /tmp/scaleway-cli && \
    mv /tmp/scaleway-cli /bin/scw && \
    scw version
RUN wget "https://github.com/golangci/golangci-lint/releases/download/v${GOLANGCI_LINT_VERSION}/golangci-lint-${GOLANGCI_LINT_VERSION}-linux-amd64.tar.gz" -O /tmp/golangci-lint.tar.gz && \
    tar -xf /tmp/golangci-lint.tar.gz -C /tmp && \
    mv /tmp/golangci-lint-${GOLANGCI_LINT_VERSION}-linux-amd64/golangci-lint /tmp/golangci-lint && \
    chmod +x /tmp/golangci-lint && \
    mv /tmp/golangci-lint /bin/golangci-lint && \
    golangci-lint --version
RUN go install github.com/a-h/templ/cmd/templ@v0.3.1001 && \
    templ version
RUN rm -rf /tmp/* && mkdir /src
WORKDIR /src

Prenons le temps de décortiquer cette image. Nous avons principalement trois étapes RUN qui vont nous permettre de télécharger et d’installer les binaires nécessaires à notre CI : golangci-lint, templ et scaleway-cli (utile pour le déploiement mais hors sujet ici). Go et sa toolchain seront déjà installés, car nous partons d’une image de base qui les contient : golang:1.26.2. Nous devons également pin l’image de base afin d’être sur de pull toujours la même.

Il suffit ensuite de builder cette image Docker :

docker build . -t ghcr.io/dhawos/nix-docker-blog-post-ci:v0.3.0 -f Dockerfile.ci

Une fois cette commande exécutée, toute personne lançant un conteneur à partir de cette image aura exactement la même panoplie d’outils. En effet, une image correspond à un “snapshot” de filesystem, ce qui est assez similaire à une image de machine virtuelle.

En revanche, ce filesystem est isolé de la machine hôte et ne contient donc pas les sources de notre application. Nous pourrions les y mettre, mais cela nécessiterait de recréer une image Docker à chaque petite modification, ce qui serait très lourd. Pour ce cas d’usage, il vaut mieux monter un volume dans notre conteneur. En local, cela donnerait :

docker run -it -v $(pwd):/src ghcr.io/dhawos/nix-docker-blog-post-ci:v0.3.0

L’option -v nous permet de faire correspondre un dossier de notre machine hôte à un emplacement dans notre conteneur (ici /src). À partir de là, nous serons capables de lancer toutes les commandes nécessaires pour travailler sur le projet.

Cependant, à ce stade, nous ne pouvons l’utiliser qu’en local. Pour que tout le monde puisse réellement utiliser la même image, il faut la pousser sur un registry :

docker push ghcr.io/dhawos/nix-docker-blog-post-ci:v0.3.0

Ensuite, nous pouvons l’utiliser directement dans nos workflows GitHub, à condition que le registry soit public. S’il ne l’est pas, il faudra gérer l’authentification pour que GitHub puisse pull l’image concernée.

name: Run workflow with docker
on:
  push:

env:
  IMAGE_TAG: ${{ github.sha }}-nix

permissions:
  contents: read
  id-token: write

jobs:
  codegen:
    name: "Ensure code is generated"
    runs-on: ubuntu-latest
    container:
      image: ghcr.io/dhawos/nix-docker-blog-post-ci:v0.3.0
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
      - run: scripts/codegen.sh
  lint:
    name: "Lint code"
    runs-on: ubuntu-latest
    container:
      image: ghcr.io/dhawos/nix-docker-blog-post-ci:v0.3.0
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
      - run: golangci-lint run
  test:
    name: "Test code"
    runs-on: ubuntu-latest
    needs:
      - codegen
    container:
      image: ghcr.io/dhawos/nix-docker-blog-post-ci:v0.3.0
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
      - run: go test ./...

Ici, c’est l’étape checkout qui permet de monter les sources dans le conteneur, c’est une partie un peu magique de GitHub Actions, mais cela fonctionne.


Nix

Voyons maintenant comment faire la même chose avec Nix.

Tout d’abord, contrairement à Docker, Nix est un gestionnaire de paquets. Ce sont donc deux technologies qui n’ont rien à voir sur le principe. La proposition de Nix est de permettre de garantir la reproductibilité des environnements.

Pour offrir ces garanties, Nix utilise des lockfiles et des checksums afin que chaque téléchargement de package, effectué à partir des mêmes fichiers, donne le même résultat. Nix vient avec son propre langage déclaratif, qui peut être intimidant (il l’a été pour moi).

Dans notre cas, nous voulons surtout mettre en place un environnement, un shell pour être plus précis, avec les programmes installés, disponibles et dans la bonne version.

Pour cela, nous pouvons utiliser les flakes de Nix. On peut voir un flake comme une “fonction” qui prend une ou plusieurs entrées et peut produire une ou plusieurs sorties. Je suis conscient que c’est très abstrait dit comme ça, mais nous allons préciser tout cela. Par exemple, dans notre cas, nous n’aurons qu’une entrée, nixpkgs, que l’on peut voir comme un dépôt contenant les paquets que l’on peut installer. C’est un simple dépôt GitHub.

inputs = {
  nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable";
};

Et nous n’aurons également qu’une sortie : un shell qui contient les binaires souhaités. Pour cela, Nix expose une fonction toute simple : mkShell.

pkgs = import nixpkgs { inherit system; };
shell = pkgs.mkShell {
  packages = [
    pkgs.go
    pkgs.golangci-lint
    pkgs.templ
    pkgs.scaleway-cli
  ];
};

Si nous voulons être capables d’utiliser ce flake sur des postes macOS et aussi des postes ou serveurs Linux, il va falloir complexifier un peu notre code, mais faisons-le pas à pas.

Tout d’abord, nous allons définir la liste des systèmes que nous souhaitons supporter :

systems = [
  "x86_64-linux"
  "aarch64-darwin"
];

Avant d’aller plus loin, il faut préciser deux choses sur le langage Nix :

  1. On peut déclarer des variables avec la construction let {} in {}. Les variables déclarées dans le bloc let seront connues dans le bloc in. Exemple :
let
  h = "Hello";
  X = "World";
in
{
  helloWorld = h + " " + X;
}

Résultat : { helloWorld = "Hello World"; }

  1. Pour appliquer une fonction à tous les éléments d’une liste, on peut utiliser la fonction map. Exemple :
builtins.map (x: x*2) [1 2 3]

Résultat : [ 2 4 6 ]

Ici, nous appliquons la fonction f(x) = x*2 à la liste [1,2,3], ce qui nous donne en retour la liste [2,4,6].

Ces éléments précisés, revenons à notre sujet. Nous allons devoir définir un shell par système que nous souhaitons supporter. Voici ce que cela va donner :

builtins.map (system: let
  pkgs = import nixpkgs { inherit system; };
  shell = pkgs.mkShell {
    packages = [
      pkgs.go
      pkgs.golangci-lint
      pkgs.templ
      pkgs.scaleway-cli
    ];
  };
in {
  name = system;
  value = {
    default = shell;
  };
}) systems

D’un point de vue haut niveau, cette fonction va retourner quelque chose comme :

[
  {
    name = "x86_64-linux",
    value = {
      default = $shell
    }
  },
  {
    name = "aarch64-darwin",
    value = {
      default = $shell
    }
  }
]

Ensuite, nous allons lui appliquer la fonction listToAttrs, qui permet de transformer une liste en attribute set (le terme en Nix qui désigne ce que l’on appelle les dictionnaires en Python ou les maps en Go). Le résultat donnera quelque chose comme :

{
  "x86_64-linux": {
    default = $shell
  }
  "aarch64-darwin": {
    default = $shell
  }
}

Or, c’est le genre d’objet qui est attendu par Nix pour pouvoir créer un shell. Il suffit donc d’associer cette valeur à la clé devShells pour que Nix comprenne que nous définissons des environnements de développement.

Mis bout à bout, voici ce que nous obtenons :

// flake.nix
{
  description = "Development environments for this project";

  inputs = {
    nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable";
  };

  outputs = {nixpkgs, ...}: let
    systems = [
      "x86_64-linux"
      "aarch64-darwin"
    ];
  in {
    devShells = builtins.listToAttrs (
      builtins.map (system: let
        pkgs = import nixpkgs { inherit system; };
        shell = pkgs.mkShell {
          packages = [
            pkgs.go
            pkgs.golangci-lint
            pkgs.templ
            pkgs.scaleway-cli
          ];
        };
      in {
        name = system;
        value = {
          default = shell;
        };
      }) systems
    );
  };
}

Si toute la syntaxe n’est pas encore claire, ce n’est pas grave, il faut mettre les mains dedans pour l’apprendre. Mais cela en vaut la peine, car ensuite, il suffit de lancer cette commande :

nix develop
$ which go
/nix/store/ckcq2mj8zk0drhaaacy6mp9d924hnr4m-go-1.26.1/bin/go
$ which golangci-lint
/nix/store/j4w0z1lgrs4fdds7npk2bnby5ks612cf-golangci-lint-2.11.4/bin/golangci-lint
$ which scw
/nix/store/mn077hkqy1bs3dwxkvxi15l0k7vz03mk-scaleway-cli-2.54.0/bin/scw
$ which templ
/nix/store/v4qj3mml7lyps2nicavbys97w56dxvjy-templ-0.3.1001/bin/templ

Le temps de télécharger les binaires si ce n’est pas déjà fait, et nous voilà plongés dans un shell avec tous les outils nécessaires pour travailler. Il faut immédiatement préciser deux choses :

Pour pouvoir l’utiliser dans la CI, il faut adapter un peu nos workflows. Voici ce que cela donne :

name: Run workflow with nix
on:
  push:

env:
  IMAGE_TAG: ${{ github.sha }}-nix

jobs:
  codegen:
    name: "Ensure code is generated"
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
      - uses: cachix/install-nix-action@616559265b40713947b9c190a8ff4b507b5df49b # v31.10.4
      - uses: DeterminateSystems/magic-nix-cache-action@565684385bcd71bad329742eefe8d12f2e765b39 # v13
      - shell: "nix develop -c bash -e {0}"
        run: scripts/codegen.sh
  lint:
    name: "Lint code"
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
      - uses: cachix/install-nix-action@616559265b40713947b9c190a8ff4b507b5df49b # v31.10.4
      - uses: DeterminateSystems/magic-nix-cache-action@565684385bcd71bad329742eefe8d12f2e765b39 # v13
      - shell: "nix develop -c bash -e {0}"
        run: golangci-lint run
  test:
    name: "Test code"
    runs-on: ubuntu-latest
    needs:
      - codegen
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
      - uses: cachix/install-nix-action@616559265b40713947b9c190a8ff4b507b5df49b # v31.10.4
      - uses: DeterminateSystems/magic-nix-cache-action@565684385bcd71bad329742eefe8d12f2e765b39 # v13
      - shell: "nix develop -c bash -e {0}"
        run: go test ./...

La seule action qui nous reste ici est celle qui permet d’installer Nix dans les runners GitHub, car ce n’est pas le cas par défaut. Pour éviter de tout retélécharger à chaque fois, il est également nécessaire de configurer un cache, ce que fait automatiquement l’action DeterminateSystems/magic-nix-cache-action.


Conclusion

J’espère vous avoir montré comment mettre en place une CI/CD fiable, facile à maintenir grâce à sa reproductibilité, et sécurisée en évitant de tout retélécharger, ce qui limite une bonne partie des attaques de type supply-chain.

L’objectif de cet article n’est pas de dire que Docker est mieux que Nix ou l’inverse, mais plutôt de présenter deux manières de répondre à la même problématique. Si l’on tente de résumer les conséquences de l’utilisation de l’une ou l’autre de ces options, voici ce que nous pourrions dire :

Si vous voulez aller plus loin, le code source lié à cet article est disponible ici : https://github.com/dhawos/nix-docker-blog-post. N’hésitez pas à le parcourir.

Que cet article vous ait été utile ou que vous ayez remarqué des erreurs ou des imprécisions, n’hésitez pas à me le faire savoir sur mon Bluesky.

Merci de m’avoir lu et à bientôt.