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 avecclippy
et fais un check de formatage (viacargo 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 -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.