Gestion de paquets et DevOps avec Nix, tour d'horizon sur un cas concret

Voir aussi : projet d’exemple - video youtube - video peertube - article linuxfr

Nix et GNU Guix sont des gestionnaires de paquets “fonctionnels”, au sens de la programmation fonctionnelle. Cette approche de la gestion de paquets est très différente de l’approche habituellement utilisée par les sytèmes Linux ou BSD, à base de collections de ports ou de dépôts de paquets.

Cette approche fonctionnelle apporte de nombreux avantages. Non seulement elle permet de fournir une gestion de paquet fiable, reproductible, multi-version et multi-utilisateur, mais apporte également de nombreuses fonctionnalités supplémentaires : gestion d’un environnement de développement, packaging décentralisé, construction d’images Docker ou de machines virtuelles, personnalisation de tout l’environnement logiciel, etc.

Cet article part d’un projet de code (un serveur web) et illustre progressivement différentes fonctionnalités de Nix intéressante pour le développeur, l’empaqueteur et l’administrateur système. Les exemples sont présentés ici sous NixOS (la distribution Linux basée sur Nix) mais devraient être utilisables également avec l’outil Nix installé sur une distribution Linux classique, ou avec GNU Guix.

Projet d’exemple : mymathserver

Le projet mymathserver est un serveur web. La route “/” retourne un texte d’accueil et la route “/mul2” permet de multiplier un entier par 2.

Le projet est codé en C++. Il contient une bibliothèque de base src/mymath.hpp:

#pragma once

int mul2(int x) {
    return 2*x;
}

Un exécutable de tests unitaires src/mymathtest.cpp (on ne sait jamais):

#include <gtest/gtest.h>

#include "mymath.hpp"

TEST(Mymath, mul2_1) {
    ASSERT_EQ(0, mul2(0));
}

// ...

int main(int argc, char **argv) {
    ::testing::InitGoogleTest(&argc, argv);
    return RUN_ALL_TESTS();
}

Et enfin le serveur web src/mymathserver.cpp:

#include "mymath.hpp"
#include <cpprest/http_listener.h>
// ...

class App : public web::http::experimental::listener::http_listener {
    private:
        void handleGet(web::http::http_request req) {
            // ...
            if (path == "/") {
                // ...
            }
            else if (splitPath.size() == 2 and splitPath[0] == "mul2") {
                // ...
            }
            else {
                req.reply(web::http::status_codes::NotFound);
            }
        }

    public:
        App(std::string url) : web::http::experimental::listener::http_listener(url) {
            support(web::http::methods::GET, 
                    bind(&App::handleGet, this, std::placeholders::_1));
        }
};

int main() {
    const char * portEnv = std::getenv("PORT");
    const std::string port = portEnv == nullptr ? "3000" : portEnv;
    const std::string address = "http://0.0.0.0:" + port;
    App app(address);
    // ...
    return 0;
}

Le tout est configuré, de façon très classique, avec un CMakeLists.txt:

cmake_minimum_required( VERSION 3.0 )
project( mymathserver )

find_package( GTest REQUIRED )
add_executable( mymathtest src/mymathtest.cpp )
target_include_directories( mymathtest PRIVATE ${GTEST_INCLUDE_DIRS} )
target_link_libraries( mymathtest
    ${GTEST_LIBRARIES} ${CMAKE_THREAD_LIBS_INIT} )

find_package( Boost REQUIRED system )
find_package ( Threads REQUIRED )
find_package ( OpenSSL REQUIRED )
add_executable( mymathserver src/mymathserver.cpp )
target_link_libraries( mymathserver PRIVATE
   cpprest ${Boost_LIBRARIES} ${OPENSSL_LIBRARIES} ${CMAKE_THREAD_LIBS_INIT} )

install( TARGETS mymathserver mymathtest DESTINATION bin )

Packager une dépendance manquante

On a donc le programme de tests unitaires mymathtest, qui utilise la bibliothèque gtest, et le programme serveur mymathserver, qui utilise boost, openssl et cpprestsdk. Toutes ces dépendances sont classiques et donc présentes dans la plupart des logithèques Linux. Cependant cpprestsdk est beaucoup moins connue que les autres dépendances et risque de manquer lors de la compilation de notre projet. Pour éviter ce problème, on peut laisser l’utilisateur installer ou compiler cpprestsdk lui-même (ce qui peut être compliqué ou fastidieux), ou intégrer cpprestsdk directement à notre projet via les sous-modules git (ce qui est peu efficace et rend le projet dépendant de git).

Avec Nix, on peut créer nous-même un paquet pour cpprestsdk. Certaines bibliothèques peuvent être complexes à empaqueter mais généralement, cela se résume à indiquer l’adresse du code source et la liste des dépendances (nix/cpprestsdk.nix) :

{ stdenv, fetchFromGitHub, cmake, boost, openssl, websocketpp, zlib }:

stdenv.mkDerivation {

  name = "cpprestsdk";

  src = fetchFromGitHub {
    owner = "Microsoft";
    repo = "cpprestsdk";
    rev = "v2.10.14";
    sha256 = "0z1yblqszs7ig79l6lky02jmrs8zmpi7pnzns237p0w59pipzrvs";
  };

  buildInputs = [ boost cmake openssl websocketpp zlib ];
}

On remarquera qu’on n’a écrit aucune directive de compilation. En effet, comme on a indiqué cmake dans les dépendances de cpprestsdk, Nix sait qu’il faut compiler avec les commandes cmake classiques.

Packager le projet

De la même façon qu’on a empaqueté cpprestsdk, on peut créer un fichier nix/mymathserver.nix pour empaqueter notre projet :

{ stdenv, cpprestsdk, boost, cmake, gtest, openssl }:

stdenv.mkDerivation {
  name = "mymathserver";
  version = "0.1";
  src = ../.;
  buildInputs = [ boost cmake cpprestsdk gtest openssl ];
}

De même que pour cpprestsdk, comme on a mis cmake dans les dépendances, Nix sait qu’il faut utiliser cmake et notre fichier CMakeLists.txt pour compiler le projet.

Pour l’instant, Nix ne connait pas le code exact des dépendances. Il connait juste les noms, et ce sont des paramètres du paquet (la première ligne du fichier). D’où la fameuse “approche fonctionnelle” de Nix.

Pour terminer, on écrit un fichier default.nix, qui est le point d’entrée de notre configuration de projet et fait le lien avec les fichiers précédents :

{ pkgs ? import <nixpkgs> {} }:

let
  cpprestsdk = pkgs.callPackage ./nix/cpprestsdk.nix {};
  mymathserver = pkgs.callPackage ./nix/mymathserver.nix { inherit cpprestsdk; };

in mymathserver

Ce fichier a également un paramètre (pkgs) mais avec une valeur par défaut (la logithèque système nixpkgs). Il suffit alors d’appeler nos paquets cpprestsdk et mymathserver sur la “logithèque” pkgs. Les paramètres des paquets sont fixés aux valeurs présentes dans pkgs, sauf pour le paramètre cpprestsdk dans mymathserver où c’est le paquet créé juste avant qui est utilisé (grâce au inherit). Ainsi, le default.nix permet de fournir concrètement le code à utiliser à travers les paramètres des descriptions de paquets précédentes, et donc de construire réellement les paquets correspondants.

On notera qu’on aurait pu se passer du fichier nix/mymathserver.nix et tout mettre dans le fichier default.nix. L’avantage d’utiliser deux fichiers est de rendre notre packaging plus modulaire. Par exemple, on pourrait quasiment intégrer directement nix/mymathserver.nix dans le dépôt nixpkgs et notre projet serait disponible dans la logithèque Nix officielle !

Lancer un environnement virtuel

A partir de notre fichier default.nix, on peut lancer un environnement virtuel :

nix-shell

Ceci installe les dépendances, compile cpprestsdk et initialise l’environnement. On peut alors travailler sur le code du projet et le compiler avec les commandes cmake classiques :

mkdir mybuild
cd mybuild
cmake ..
make
./mymathserver

Construire et installer le paquet du projet

Le fichier default.nix permet également de construire automatiquement le paquet du projet :

nix-build
./result/bin/mymathserver

On peut également installer notre projet comme un logiciel classique de la logithèque système.

nix-env -i f .
mymathserver

Et on peut même installer en récupérant directement une archive du dépôt git distant :

nix-env -if "https://gitlab.com/nokomprendo/mymathserver/-/archive/master/mymathserver-master.tar.gz"

Fixer une version

Le fichier default.nix construit le projet à partir de la logithèque système, qui peut varier dans le temps ou selon les utilisateurs. Nix permet de fixer une logithèque précise, et donc d’avoir un paquet reproductible. Par exemple avec le fichier nix/release.nix:

let
  rev = "1c92cdaf7414261b4a0e0753ca9383137e6fef06";

  pkgs-src = fetchTarball {
    url = "https://github.com/NixOS/nixpkgs/archive/${rev}.tar.gz";
    sha256 = "0d3fqa1aqralj290n007j477av54knc48y1bf1wrzzjic99cykwh";
  };

  pkgs = import pkgs-src {};
  
in pkgs.callPackage ../default.nix {}

Encore une fois, c’est l’approche fonctionnelle de Nix qui permet de composer les fichiers de configuration entre eux. Ici nix/release.nix se contente de récupèrer un pkgs particulier et s’en sert comme paramètre de default.nix pour construire tout le projet et ses dépendances à partir de ce pkgs particulier.

On peut alors construire ce paquet reproductible avec la commande :

nix-build nix/release.nix

Intégration continue

Comme notre configuration Nix décrit complètement l’environnement et la construction de notre projet, il est très pratique de l’utiliser dans un processus d’intégration continue. Par exemple, avec gitlab-ci, on peut ajouter le fichier .gitlab-ci.yml suivant pour lancer la compilation du projet et les tests unitaires.

build:
    image: nixos/nix
    script:
        - nix-build nix/release.nix
        - ./result/bin/mymathtest

Cache binaire

Nix permet d’utiliser des paquets de binaires pour éviter d’avoir à tout compiler soi-même. Ainsi, la logithèque nixpkgs fournit un cache binaire. Cependant, cpprestsdk n’est pas disponible dans ce cache car on l’a packagé nous-même. Nix propose des outils pour mettre en place des serveurs d’intégration continue et de cache mais ceci est assez lourd à mettre en place.

Une autre possibilité est d’utiliser le service de cache cachix, qui permet d’uploader des paquets Nix compilés puis de les télécharger sur un autre système.

L’utilisation de cachix est très simple. Il suffit de créer un compte et un dépôt de cache. On peut ensuite envoyer des paquets binaires avec la commande cachix push et activer le téléchargement depuis un dépôt de cache avec la commande cachix use.

Par exemple, pour accélerer l’intégration continue précédente, on peut construire cpprestsdk avec notre configuration release, uploader les paquets binaires correspondants dans le dépôt nokomprendo puis utiliser ce cache dans le processus d’intégration continue (.gitlab-ci.yml) :

build:
    image: nixos/nix
    script:
        - nix-env -iA nixpkgs.cachix
        - cachix use nokomprendo
        - nix-build nix/release.nix
        - ./result/bin/mymathtest

Ceci fait passer l’exécution d’un processus d’intégration continue de 20 minutes à moins de 2 minutes.

Construire et déployer une image Docker

Nix permet de construire des images Docker. Par exemple, le fichier nix/docker.nix suivant définit une image Docker à partir de notre configuration release:

{ pkgs ? import <nixpkgs> {} }:

let
  mymathserver = import ./release.nix;

  entrypoint = pkgs.writeScript "entrypoint.sh" ''
    #!${pkgs.stdenv.shell}
    $@
  '';

in pkgs.dockerTools.buildImage {
  name = "mymathserver";
  tag = "latest";
  config = {
    WorkingDir = "${mymathserver}";
    Entrypoint = [ entrypoint ];
    Cmd = [ "${mymathserver}/bin/mymathserver" ];
  };
}

On peut alors construire l’image, la charger dans Docker et la tester :

nix-build nix/docker.nix
docker load < result
docker run --rm -it -p 3000:3000 mymathserver:latest

Cette image Docker peut être déployée, par exemple sur Heroku :

heroku login
heroku container:login
heroku create nokomprendo-mymathserver
docker tag mymathserver:latest registry.heroku.com/nokomprendo-mymathserver/web
docker push registry.heroku.com/nokomprendo-mymathserver/web
heroku container:release web --app nokomprendo-mymathserver
heroku logs --tail

L’application est alors accessible à l’adresse http://nokomprendo-mymathserver.herokuapp.com

Construire et déployer une machine virtuelle

Nix permet de définir et de déployer des machines virtuelles, avec nixops. Par exemple, le fichier nix/virtualbox.nix suivant reprend notre programme mymathserver, le lance dans un service systemd et déploie le tout dans une virtualbox :

{
  network.description = "mynetwork";

  myserver = { config, pkgs, ... }: 

  let
    myapp = import ./release.nix;

  in {
    networking.firewall.allowedTCPPorts = [ 3000 ];

    systemd.services.myservice = {
      wantedBy = [ "multi-user.target" ];
      after = [ "network.target" ];
      script = "${myapp}/bin/mymathserver";
    };

    deployment = {
      targetEnv = "virtualbox";
      virtualbox = {
        memorySize = 512; 
        vcpu = 1; 
        headless = true;
      };
    };
  };
}

Les commandes suivantes créent et déploient la machine virtuelle sous le nom myvm :

nixops create -d myvm nix/virtualbox.nix
nixops deploy -d myvm --force-reboot

A l’issu du déploiement, une adresse IP est fournie et permet d’accèder à notre serveur sur la machine virtuelle.

Personnaliser des paquets existants

Enfin, une fonctionnalité originale de Nix, liée à son “approche fonctionnelle”, est la possibilité de modifier les paramètres des paquets et de répercuter automatiquement et efficacement ces modifications dans l’ensemble de l’environnement logiciel.

Les paquets peuvent être surchargés, c’est-à-dire remplacés par des versions modifiées. Par exemple, le paquet de la bibliothèque zlib contient un attribut configureFlags qui contient les options de compilation à utiliser. Si on veut utiliser une version de zlib compilée avec l’option --zprefix, on peut utiliser le fichier nix/custom0.nix suivant :

let
  pkgs = import <nixpkgs> {};

  zlib = pkgs.zlib.overrideDerivation (attrs: {
     configureFlags = [ attrs.configureFlags "--zprefix" ];
  });

  cpprestsdk = pkgs.callPackage ./cpprestsdk.nix { inherit zlib; };

  mymathserver = pkgs.callPackage ./mymathserver.nix { inherit cpprestsdk; };

in pkgs.stdenv.mkDerivation rec {
  name = "mymathserver-custom0";
  src = ./.;
  buildPhase = "";
  installPhase = ''
    mkdir -p $out/bin
    cp ${mymathserver}/bin/mymathserver $out/bin/${name}
  '';
}

Les paramètres des paquets Nix peuvent également contenir des options paramétrables. Par exemple, la bibliothèque openssl a une option enableSSL2, qu’on peut spécifier comme dans le fichier nix/custom1.nix suivant :

let
  pkgs = import <nixpkgs> {};

  openssl = pkgs.openssl.override { enableSSL2 = true; };

  cpprestsdk = pkgs.callPackage ./cpprestsdk.nix { inherit openssl; };

  mymathserver = pkgs.callPackage ./mymathserver.nix {
    inherit cpprestsdk;
    inherit openssl;
  };

in pkgs.stdenv.mkDerivation rec {
  name = "mymathserver-custom1";
  src = ./.;
  buildPhase = "";
  installPhase = ''
    mkdir -p $out/bin
    cp ${mymathserver}/bin/mymathserver $out/bin/${name}
  '';
}

Enfin, pour éviter tout risque d’incompatibilité d’ABI, on peut surcharger la configuration lors du chargement de la logithèque. Ainsi, tous les paquets dépendant des paquets surchargés seront recompilés en prenant en compte ces modifications. Par exemple, le fichier nix/custom2.nix suivant surcharge openssl via la configuration de la logithèque (il existe aussi un système d’overlays pour cela) :

let

  config = {
    packageOverrides = pkgs: {
      openssl = pkgs.openssl.override {
        enableSSL2 = true;
      };
    };
  };

  pkgs = import <nixpkgs> { inherit config; };

  mymathserver = pkgs.callPackage ../default.nix {};

in pkgs.stdenv.mkDerivation rec {
  name = "mymathserver-custom2";
  src = ./.;
  buildPhase = "";
  installPhase = ''
    mkdir -p $out/bin
    cp ${mymathserver}/bin/mymathserver $out/bin/${name}
  '';
}

Nix installe chaque paquet dans un dossier spécifique ce qui signifie qu’on peut utiliser en même temps plusieurs versions ou plusieurs configurations d’un même logiciel. Par exemple, dans les trois fichiers de personnalisation précédents, on a renommé l’exécutable mymathserver produit. On peut donc les installer et les exécuter en même temps dans l’environnement utilisateur courant :

nix-env -i -f nix/custom0.nix
nix-env -i -f nix/custom1.nix
nix-env -i -f nix/custom2.nix
mymathserver-custom0
...

Conclusion

L’approche fonctionnelle de Nix constitue une base solide pour la gestion de paquet. Elle permet de construire des paquets de façon fiable, reproductible et personnalisable. Sur cette base, Nix propose également des fonctionnalités intéressantes pour le développeur, l’empaqueteur et l’administrateur système.

Ainsi, en 120 lignes de code Nix, le projet d’exemple mymathserver :

mymathserver/
├── CMakeLists.txt
├── default.nix
├── nix
│   ├── cpprestsdk.nix
│   ├── custom0.nix
│   ├── custom1.nix
│   ├── custom2.nix
│   ├── docker.nix
│   ├── mymathserver.nix
│   ├── release.nix
│   └── virtualbox.nix
└── src
    ├── mymath.hpp
    ├── mymathserver.cpp
    └── mymathtest.cpp