Published by

Il y a 6 mois -

Temps de lecture 10 minutes

Autour des conteneurs : les conteneurs de build & le build multi-stage

Comme nous avons pu le voir précédemment, un conteneur est un processus.
Et des processus, nous en avons énormément qui s’exécutent sur notre système, pour plusieurs cas d’utilisation.

Lorsque l’on est développeur, nous pouvons être amenés à :

  • écrire du code
  • compiler ce code
  • initialiser un environnement pour y lancer des tests

Les cas d’utilisation des conteneurs sont totalement identiques.
Nous l’avons dit, un conteneur est un processus : il est donc normal que nous les utilisions de la même manière.

Ainsi, nous pourrons avoir :

  • des conteneurs de “run”
  • des conteneurs de “build”
  • des conteneurs de tests
  • des conteneurs d’initialisation

La problématique

Mettons nous en situation.

Récemment, un collègue m’a dit

Eh, regarde, j’ai commencé à coder en Rust. Pour tester, j’ai fait une petite appli qui va récupérer tous les liens d’une page web. Ca s’appelle rust-crawler, tu veux tester ?

De nature curieuse, j’ai accepté. Il m’a en revanche mis en garde

Si tu veux contribuer, n’hésite pas.
Par contre, compile avec clippy et fais un check de formatage (via cargo fmt --all --check)
avant de valider ton code.

J’ai donc vérifié que j’avais bien clippy sur ma toolchain Rust (Rustup) et lancé la construction de l’application.

cargo fmt --all -- --check && cargo clippy && cargo build --release --bin rust-crawler

Et là, c’est le drame…

cargo fmt --all -- --check && cargo clippy && cargo build --release --bin rust-crawler
    Checking scopeguard v1.0.0
    Checking either v1.5.3
    Checking lazy_static v1.4.0
    Checking cfg-if v0.1.10
    Checking fnv v1.0.6
    Checking futures v0.1.29
    Checking slab v0.4.2
    Checking matches v0.1.8
    Checking smallvec v1.1.0
    Checking new_debug_unreachable v1.0.4
    Checking rand_core v0.4.2
    Checking siphasher v0.2.3
    Checking foreign-types-shared v0.1.1
    Checking itoa v0.4.5
    Checking mac v0.1.1
error[E0658]: use of unstable library feature 'alloc': this library is unlikely to be stabilized in its current form or name (see issue #27783)
  --> /home/gocho/.cargo/registry/src/github.com-1ecc6299db9ec823/smallvec-1.1.0/lib.rs:35:1
   |
35 | extern crate alloc;
   | ^^^^^^^^^^^^^^^^^^^

error[E0658]: use of unstable library feature 'maybe_uninit' (see issue #53491)
  --> /home/gocho/.cargo/registry/src/github.com-1ecc6299db9ec823/smallvec-1.1.0/lib.rs:49:5
   |
49 | use core::mem::MaybeUninit;
   |     ^^^^^^^^^^^^^^^^^^^^^^

Des erreurs de partout ! Directement à la compilation. Mon collègue n’en revient pas et ne comprend pas. Sur son poste, aucun souci

Après quelques vérifications, il se trouve que ma toolchain Rust commence à dater. Version 1.33
La sienne est en version 1.36, qui n’est pas la dernière non plus (1.40 à date d’écriture de cet article).

Du coup, ce point a été ajouté dans le readme du dépôt de code, afin que les développeurs suivants n’aient pas la même surprise.

Comment pourrait-on éviter ce genre de désagréments ?
Comment pourrait-on avoir une manière unifiée de construire notre application ?
Comment s’assurer que le code que je pourrais ajouter ne poserait pas problème sur son poste ?

Vous vous en doutez, la réponse commence par “conteneur” !

Et cette même réponse finit par “de build”.

Qu’est-ce qu’un conteneur de build ?

Un conteneur de build est destiné, comme son nom le laisse supposer, à construire une application.
Il va donc par essence être éphémère et s’arrêter à la fin de la construction de ladite application.
Son comportement est strictement identique à celui de la commande de compilation que nous avons utilisé pour produire le binaire de l’application rust-crawler.

Voici ce que nous pourrions faire via le dockerfile suivant (que nous appelerons builder1.Dockerfile)

FROM ubuntu:18.04
RUN curl https://sh.rustup.rs -sSf | sh  
RUN rustup update
RUN rustup component add clippy
RUN rustup component add rustfmt

Sauf qu’avec une telle image, on ne maitrise pas la version de Rust qui sera installée (rustup installe la dernière stable en date, qui varie donc dans le temps).

En plus, cela ne donnerait rien : Rustup a besoin de quelques dépendances qui ne sont pas présentes dans notre conteneur.

Fort heureusement, la tâche nous est parfois simplifiée et il n’est pas nécessaire de créer nous mêmes nos conteneurs de build.
Les mainteneurs de Rust fournissent par exemple un ensemble d’images que nous pouvons directement utiliser.

Notre conteneur de build reviendrait donc à

FROM rust:1.36.0
RUN rustup update && rustup component add clippy && rustup component add rustfmt

Une fois construite via

docker build -t rust-clippy:1.36.0 -f builder1.Dockerfile .

Nous pourrions alors l’utiliser comme ceci

docker run -v $(pwd):/rust-crawler -w /rust-crawler rust-clippy:1.36.0

Ce qui nous permettrait de construire l’application sans même avoir Rust installé sur notre poste.

Pratique. Mais pas parfait.
Pour aller plus loin, faisons un autre Dockerfile builder-app.Dockerfile dans lequel nous construisons automatiquement notre application finale.

FROM rust-clippy:1.36.0

WORKDIR /rust-crawler
COPY . .
RUN cargo fmt --all -- --check
RUN cargo clippy
RUN cargo build --release --bin rust-crawler
CMD ./target/release/rust-crawler

Ainsi, en lançant simplement

docker build -t rust-crawler:1.36.0 -f builder-app.Dockerfile .

Nous obtenons une image contenant notre application.
Il serait également imaginable de créer un script (makefile, shell) qui ferait l’ensemble de ces opérations pour nous, mais cela n’est pas le but de cet article.

Dans quel cas peut-on être amené à utiliser un conteneur de build ?

1/ Avoir la même version d’outils sur les postes de développeurs

Nous l’avons vu précédemment, un conteneur de build permet dans un premier temps de disposer, pour tous les développeurs d’un même projet, de la même version des outils nécessaires à la construction d’une application.

2/ Garder un poste propre

Nous l’avons également vu dans l’exemple ci-dessus, il a été possible de compiler l’application sans même avoir besoin de Rust installé sur notre poste.
C’est donc plutôt pratique. On peut éviter les conflits liés à de multiples versions d’un même outil (Rust gère plutôt bien ce point, mais ce n’est pas le cas pour tous les langages…).

Un autre cas d’utilisation peut également être rapidement identifié : la plateforme d’intégration continue (CI).

3/ Plateforme d’intégration continue

Dans tout projet logiciel actuel, il existe une plateforme d’intégration continue, qui va être en charge de construire de façon automatisée les applications (car oui, de nos jours, il devrait être rare, voire inexistant, de compiler soi-même son code avant de l’envoyer en production…)

Pour construire les binaires, cette plateforme va également avoir besoin d’outils.
Il est bien évidemment possible d’installer manuellement ces outils quelque part (cf procédure vue plus haut) afin qu’ils soient disponibles pour la plateforme.
Mais de plus en plus de CI acceptent, voire même sont basées sur, les conteneurs pour effectuer leurs tâches.

Prenons l’exemple de Github Actions.
Pour compiler notre code, nous pourrions utiliser le workflow suivant

name: rust-crawler
on: [push]
jobs:
  build_and_test_app:
    runs-on: ubuntu-latest
    container: rust:1.36.0
    steps:
      - name: checkout
        uses: actions/checkout@v1
      - name: build app
        run: |
          rustup component add clippy
          rustup component add rustfmt
          cargo fmt --all -- --check
          cargo clippy
          cargo build --release --bin rust-crawler

NB : Si le workflow fonctionne bien, il n’est pas vraiment optimisé de passer notre temps à rajouter les composants rustup à chaque exécution. Il serait bien mieux de créer cette image au préalable et la sauvegarder dans un registre d’images.

Maintenant, nous sommes certains d’avoir la même version d’outils sur notre poste local et sur notre plateforme d’intégration. C’est une très bonne chose.

De plus, la mise à jour de la version de cet outil sera d’autant plus aisée qu’il suffira d’utiliser une autre image en remplacement de l’existante.

C’est fini? On est prêt ?

Pas tout à fait.
En fait, ici, nous n’avons finalement fait que créer notre conteneur de build et conteneuriser notre application rust-crawler.
C’est déjà pas mal, mais pas encore parfait.
Vous pouvez le voir au niveau du code, il nous reste toujours deux points gênants :

1- Nous avons toujours deux Dockerfile:

Un pour construire l’environnement de construction de notre application et un autre pour construire l’application elle même. Cela peut vite devenir pénible à maintenir.

2- Notre image résultante contient l’ensemble des outils nécessaires pour construire l’application.

Vous me direz, et donc ? Et bien, voyons ce que cela veut dire

Images docker : 
rust-clippy   1.36.0  5c9804a652d8        About an hour ago   1.78GB
rust-crawler  1.36.0  86787e0b28c4        16 minutes ago      3.29GB

Binaire rust-crawler: 
-rwxr-xr-x   2 user user  68M Jan 26 23:27 rust-crawler

Constat assez désagréable. Nous avons plus de 4Go d’images sur notre poste, juste pour un binaire qui fait, lui, 68 Mo.
C’est pas terrible, il faut en convenir.

Et bien heureusement, ces deux points peuvent être améliorés via un mécanisme intégré dans Docker nommé le multi-stage build

Multi-stage build

Le build multi-stage est une fonctionnalité ajoutée dans Docker à compter de la version 17.05 et qui permet de décrire dans un seul et même Dockerfile l’ensemble des opérations nécessaires à la construction de notre application finale.

Ce que va faire Docker “en sous-marin” est de créer des conteneurs intermédiaires qui ne sont ni plus ni moins que de nouveaux layers ( plus de détails sur les layers ) et permettre à l’utilisateur de les réutiliser. Cette possibilité de réutilisation est la clé du mécanisme de multi-stage.

Ainsi, les conteneurs intermédiaires sont encore plus éphémères, puisqu’il n’est même plus requis de les construire séparément, ni même de se préoccuper de savoir où ils vont être stockés.

Il devient également possible de ne plus avoir les outils de construction dans l’image finale : l’image construite devient alors plus légère. Notre stockage et notre réseau nous remercieront.

Voyons ce que cela donnerait dans notre cas

FROM rust:1.36.0 as builder
  
WORKDIR /rust-crawler
COPY . .
RUN rustup update && rustup component add rustfmt && rustup component add clippy
RUN cargo fmt --all -- --check
RUN cargo clippy
RUN cargo build --release --bin rust-crawler

FROM debian:stretch-slim
RUN apt-get update -y && \
    apt-get install -y libpq-dev openssl libssl1.0-dev ca-certificates

WORKDIR /rust-crawler
COPY --from=builder /rust-crawler/target/release/rust-crawler .

CMD ./rust-crawler

Et la taille de la nouvelle image

rust-crawler-multi  1.36.0  46e23a3dc799        2 minutes ago       105MB             

105 Mo vs +4 Go. Nous sommes sur un facteur de +38 !
Si la réduction de la taille de l’image n’est pas le but premier de cet article (ça aussi, nous le verrons dans un prochain article), nous pouvons quand même être contents du résultat.

Conclusion

Nous avons pu voir le bénéfice qu’apporte les conteneurs de build :

  • La reproductibilité : un build n’est plus soumis à l’environnement local
  • La portabilité : Je peux donner mon code à quelqu’un qui ne possède même pas les outils nécessaires à construire mon projet, il pourra quand même construire et utiliser l’application.
  • Optimisation :
    • Il est possible de décorréler les étapes de construction des étapes d’exécution de notre application, notamment via le build multi-stage. Ainsi, tout ce qui concerne la construction et pas l’exécution ne sera pas présent dans l’image résultante.
    • La taille de l’image résultante peut-être sensiblement diminuée et ainsi laisser vivre tranquillement nos ordinateurs sans les submerger.

Vous pouvez retrouver l’ensemble des sources liées à cet article dans ce dépôt.

Published by

Commentaire

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Nous recrutons

Être un Sapient, c'est faire partie d'un groupe de passionnés ; C'est l'opportunité de travailler et de partager avec des pairs parmi les plus talentueux.