Construire et publier des conteneurs sans daemon Docker

Publié le 11/04/2023

J’ai pas mal travaillé sur la CI/CD de Garage, et force est de constater qu’on a rencontré un nombre incroyable de problèmes. Entre autre, on a noté que les builds Rust sans cache sont trop lents par rapport à nos attentes, qu’il n’y avait pas de solution légère pour gérer les artefacts binaires et enfin que construire un conteneur quand on a un CI/CD à base de Docker, ça n’était pas possible car on n’avait pas accès au daemon docker ni la possibilité de faire du “docker in docker” de manière à peu près sécurisée.

Si la question du cache et des artefacts binaires est passionnante, nous allons la garder pour un autre billet de blog, et nous focaliser sur comment construire des conteneurs légers, multi-plateforme et les publier dans ce billet. Si vous ne voyez pas ce que j’entends par registre statique, allez donc jeter un coup d’oeil à mon précédent billet !

Alors maintenant qu’on a notre périmètre, décortiquons le:

À noter qu’il y a un dernier point qui ne sera pas abordé dans ce billet qui sera sans aucun doute beaucoup trop long de toute manière, c’est comment gérer la garbage collection de nos artifacts.

Une build file avec Nix Flake

Pour ce billet, on va prendre comme un exemple un programme en go que j’ai écrit, Albatros, ma propre CI/CD (ça devient déjà meta). L’avantage de prendre comme exemple un programme en Go, c’est que ça se cross compile facilement. Voilà un extrait du fichier flake.nix de notre projet :

# declare the go module of this package, allow for cross compilation
gopkg = arch: (pkgs.buildGoModule rec {
  pname = "albatros-go-module";
  version = "0.9";
  CGO_ENABLED = 0;
  # ... skip
}).overrideAttrs (old: old // { GOOS = "linux"; GOARCH = arch; });

# logic to build static binaries
albatrosStaticBin = #... extract the binary from gopkg (skipped here)

# logic to build docker containers
docker = (staticBin: arch: pkgs.dockerTools.buildImage {
  name = "dxflrs/albatros";
  architecture = arch;
  config = {
    Cmd = [ "${staticBin}" ];
  };
});

# map nixos/llvm arch to golang arch
archmap = {
  "aarch64-linux" = "arm64";
  "x86_64-linux" = "amd64";
  "i686-linux" = "386";
  "armv6l-linux" = "arm";
};

# generate packages for each architecture
packages = builtins.mapAttrs (name: value: {
  docker.albatros = (docker (albatrosStaticBin value) value);
  # other targets (skipped)...
}) archmap;

On peut consulter le fichier en entier sur la forge d’Albatros.

Ce fichier est relativement simple à lire une fois qu’on sait comment l’aborder.

En fait on construit par rafinement successif. Le premier bloc consiste en une fonction qui permet de compiler un module Go à partir de la recette fournie par la bibliothèque standard NixOS. Je dis bien une fonction, car ce bloc prend en paramètre arch qui contient l’architecture cible de notre module. Ainsi, si on lui passe arm64 on aura un binaire qui fonctionne sur les processeurs ARM 64 bits, si on passe 386, on aura un binaire pour les vieux PC x86 32 bits, etc.

Dans les blocs suivants, on raffine donc ce premier module. On va d’abord avoir une fonction qui va extraire le binaire statique du module généré par Go, ensuite une fonction Docker qui va mettre ce binaire statique dans un conteneur.

Enfin, une fois notre logique définie, on va déclarer quelles architectures on choisit de supporter, là j’en ai choisi 4. On va donc faire une boucle (mapAttrs) pour générer le conteneur Docker qui va bien pour chaque architecture.

Créer les artefacts avec nix build

On peut ensuite créer nos différentes archives Docker, en précisant le chemin de sortie pour s’y retrouver :

nix build .#packages.x86_64-linux.docker.albatros -o albatros.amd64.tar.gz
nix build .#packages.armv6l-linux.docker.albatros -o albatros.arm.tar.gz
nix build .#packages.aarch64-linux.docker.albatros -o albatros.arm64.tar.gz
nix build .#packages.i686-linux.docker.albatros -o albatros.386.tar.gz

Construire notre image multi-arch

Dans le monde des conteneurs, une image multiarch est juste une indirection, un fichier qui contient une liste de manifest avec des tags pour leur OS et leur architecture. Il faut donc créer un fichier qui liste le manifest de chacune de nos 4 images.

Problème : aujourd’hui il n’y a pas vraiment d’outils clé en main. Typiquement, une issue sur skopeo traine depuis 3 ans maintenant (2020) sans qu’elle n’ait jamais été résolue. On va essayer de bidouiller un truc de notre côté.

On va extraire chacun de ces fichiers sous forme de dossier avec skopeo.

Il y a deux façons de représenter des images de conteneur sous forme de dossier avec skopeo : via le transport _dir_ et le transport _oci_. Le premier est un format interne non spécifié à skopeo. Le second est standardisé et a donc une spécification en bonne et due forme. Nous, on va préférer utiliser un standard pour éviter les mauvaises surprises à l’avenir (changement de format, abandon du support, interopérabilité, etc.).

mkdir -p /tmp/oci
skopeo --insecure-policy copy docker-archive:albatros.amd64.tar.gz oci:/tmp/oci/amd64
skopeo --insecure-policy copy docker-archive:albatros.arm64.tar.gz oci:/tmp/oci/arm64
skopeo --insecure-policy copy docker-archive:albatros.arm.tar.gz oci:/tmp/oci/arm
skopeo --insecure-policy copy docker-archive:albatros.386.tar.gz oci:/tmp/oci/386

On va ensuite construire à la main le dossier multiarch :

mkdir -p /tmp/oci/multi

# on copie juste le fichier qui déclare la version de la spec "directory" de OCI
cp /tmp/oci/amd64/oci-layout /tmp/oci/multi/

# on copie les blobs
mkdir -p /tmp/oci/multi/blobs/sha256/
cp -r /tmp/oci/{386,arm,arm64,amd64}/blobs/sha256/* /tmp/oci/multi/blobs/sha256/

Maintenant il ne nous reste plus qu’à créer un manifeste depuis les informations qu’on a collecté ! Pour se faire, deux choix : soit on copie, soit on va voir la spec.

Pour copier, on peut aller zyeuter du côté d’une image officielle de Docker, comme celle de redis par exemple :

docker manifest inspect redis:latest

Ce qui nous donne :

{
   "schemaVersion": 2,
   "mediaType": "application/vnd.docker.distribution.manifest.list.v2+json",
   "manifests": [
      {
         "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
         "size": 1573,
         "digest": "sha256:94a25c195c764f7962087eda247471989797001c222f079d5d4dbb1c34cc4854",
         "platform": {
            "architecture": "amd64",
            "os": "linux"
         }
      },
      {
         "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
         "size": 1573,
         "digest": "sha256:8c6ff1f41fa800338843b4f6e1783faa1d3db95ac2c9e2ef2255ae01098349c8",
         "platform": {
            "architecture": "arm",
            "os": "linux",
            "variant": "v5"
         }
      },
      // ...
   ]
}

L’autre option, c’est d’aller voir la specification OCI sur les index d’images.

À noter que vu qu’on est passé sur de l’OCI, notre mediaType est celui de OCI (vnd.oci.image.manifest.v1+json) et non celui de Docker ( application/vnd.docker.distribution.manifest.v2+json), on peut s’en apercevoir en allant regarder l’index de nos images, exemple avec /tmp/oci/amd64/index.json :

{
  "schemaVersion": 2,
  "manifests": [
    {
      "mediaType": "application/vnd.oci.image.manifest.v1+json",
      "digest": "sha256:bc16dc0ab502dedbce06f16f51d46f7027271e20a378c7f2821bf5e000197523",
      "size": 405
    }
  ]
}

En réalité, il nous faut simplement fusionner ces 4 fichiers d’index et les tagger avec la platform qui va bien ! Dans cet exemple, je vais le faire plus ou moins à la main avec jq :

jq -s '
{
  schemaVersion: 2, 
  mediaType: "application/vnd.oci.image.index.v1+json", 
  manifests: (
    [.[0].manifests[0] | .platform = { architecture:"amd64", os:"linux" }] + 
    [.[1].manifests[0] | .platform = { architecture:"arm64", os:"linux" }] + 
    [.[2].manifests[0] | .platform = { architecture:"arm", os:"linux" }] + 
    [.[3].manifests[0] | .platform = { architecture:"386", os:"linux" }]
)}' amd64/index.json arm64/index.json arm/index.json 386/index.json

Et voilà, notre image multi-arch devrait être prête !

Envoyer notre image sur le Docker Hub

Encore une fois, on ne veut toujours pas utiliser de daemon Docker. On va utiliser plutôt à la place crane, un outil développé par Google. C’est pas plus compliqué que de lancer cette commande :

crane push --index /tmp/oci/multi/ dxflrs/albatros:d9facbb79c4551d90359c46b9f5d485c1503253a

On peut ensuite vérifier que notre image multi architecture est bien référencée sur le Docker Hub :

Et la récupérer avec un simple docker pull :

docker pull dxflrs/albatros:d9facbb79c4551d90359c46b9f5d485c1503253a

Envoyer notre image sur notre registre Garage

Tout d’abord on commence par envoyer l’index :

aws s3 cp \
  --content-type 'application/vnd.oci.image.index.v1+json' \
  index.json \
  s3://registry.deuxfleurs.org/v2/albatros/manifests/0.9

Ensuite on envoie les manifests de chacune des images référencées dans l’index :

for m in $(jq -r '.manifests[] | .digest' index.json); do 
  f=$(echo $m | grep -Po '[0-9a-f]+$')
  aws s3 cp \
    --content-type 'application/vnd.oci.image.manifest.v1+json' \
    /tmp/oci/multi/blobs/sha256/$f \
    s3://registry.deuxfleurs.org/v2/albatros/manifests/$m
done

Ensuite on envoie les blobs :

for m in $(jq -r '.manifests[] | .digest' index.json); do 
  f=$(echo $m | grep -Po '[0-9a-f]+$')
  for blob in $(jq -r '[ .config.digest ] + [ .layers[] | .digest ] | join(" ")' /tmp/oci/multi/blobs/sha256/$f); do 
    bf=$(echo $blob | grep -Po '[0-9a-f]+$')
    aws s3 cp /tmp/oci/multi/blobs/sha256/$bf s3://registry.deuxfleurs.org/v2/albatros/blobs/$blob
  done
done

Et voilà, on peut tester notre nouveau registre :

docker pull registry.deuxfleurs.org/albatros:0.9
docker run --rm -it -e ALBATROS_URL="x" registry.deuxfleurs.org/albatros:0.9
# 2023/04/11 14:40:08 Albatros public URL: x
# 2023/04/11 14:40:08 Use Nomad default configuration
# 2023/04/11 14:40:08 Use Consul default configuration
# 2023/04/11 14:40:08 Albatros listen on :8080

On peut aussi vérifier que skopeo est content avec notre registre :

mkdir -p /tmp/discard
skopeo --insecure-policy copy --all docker://registry.deuxfleurs.org/albatros:0.9 oci:/tmp/discard/
# inspectez le contenu de /tmp/discard

Petite réflexion sur ce qu’on vient de faire

On a vu comment construire plusieurs images Docker simples avec NixOS, une pour chaque plateforme qu’on supporte. Ensuite on a vu comment les manipuler au format OCI pour les fusionner et créer une image multiarch. Enfin, on a vu comment l’envoyer sur le hub Docker avec crane et sur notre registre statique. Aucune de ces opérations n’a nécessité d’élévation de privilège, ni le daemon Docker, ni même Docker tout court.

Bien entendu, si j’ai semi-automatisé les opérations avec des scripts bash, il reste que cette opération reste encore quelque peu fastidieuse. Il ne devrait pas être trop compliqué de porter ces différents bouts de bash vers un binaire Go qui se chargerait alors de l’envoi de ces images.

Reste la question du périmètre de ce binaire : est-ce qu’il doit supporter le hub docker et mon registre statique ? est-ce qu’il doit être possible de supprimer une image ? est-ce qu’il doit supporter les binaires statiques aussi ? est-ce qu’il doit faire une garbage collection automatique, et si oui, selon quelles règles ? est-il bien judicieux de générer une archive docker depuis NixOS pour ensuite la convertir en OCI, puis manipuler cette image, avant de l’envoyer, ou alors mieux vaudrait réaliser le plus possible ces tâches au sein de NixOS ? Est-ce que NixOS ne devrait pas générer notre image multarch ?

C’est donc sur d’avantage de questions que de réponses que je conclue ce billet de blog, à votre tour de faire de la magie avec les conteneurs !