Comment construire ses images OCI avec Buildpacks
9 janv. 2023
Ne ratez pas nos articles sur l'open source, le big data et les systèmes distribués, fréquence faible d’un email tous les deux mois.
Docker est désormais devenu un standard pour déployer son application. Dans une image Docker on place notre code source, ses dépendances, quelques configurations et notre application est quasiment prête à être déployé sur notre machine de travail ou sur notre production, que ce soit dans le Cloud ou en interne. Depuis plusieurs années, Docker s’éclipse au profit du standard open-source OCI (Open Container Initiative). Aujourd’hui il n’est même plus nécessaire d’utiliser un Dockerfile pour builder nos applications ! Regardons ensemble ce que propose Buildpacks à ce sujet, mais pour cela nous devons d’abord comprendre ce qu’est vraiment une image OCI.
Les couches OCI
Prenons en exemple une application Node.js basique :
myapp
├── package.json
└── src
└── index.js
Pour containeriser notre application, nous avons l’habitude d’écrire un Dockerfile. Il contient les instructions nécessaires pour construire un environnement qui servira à lancer notre application.
FROM node:16
WORKDIR /app
# On ajoute notre code à notre future image
COPY package.json /app/package.json
COPY src/ /app/src
# On lance npm qui va installer les dépendances Node.js de notre application
RUN npm install
CMD 'npm start'
Une fois construite, on peut inspecter notre image avec docker inspect
: (morceaux choisis)
(...)
"Config": {
"Hostname": "",
"Domainname": "",
"User": "",
"AttachStdin": false,
"AttachStdout": false,
"AttachStderr": false,
"Tty": false,
"OpenStdin": false,
"StdinOnce": false,
"Env": [
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
"NODE_VERSION=16.17.0",
"YARN_VERSION=1.22.19"
],
"Cmd": [
"/bin/sh",
"-c",
"npm start"
],
"Image": "sha256:ca5108589bcee5007319db215f0f22048fb7b75d4d6c16e6310ef044c58218c0",
"Volumes": null,
"WorkingDir": "/app",
"Entrypoint": [
"docker-entrypoint.sh"
],
"OnBuild": null,
"Labels": null
},
(...)
"Type": "layers",
"Layers": [
"sha256:20833a96725ec17c9ab15711400e43781a7b7fe31d23fc6c78a7cca478935d33",
"sha256:07b905e91599cd0251bd575fb77d0918cd4f40b64fa7ea50ff82806c60e2ba61",
"sha256:5cbe2d191b8f91d974fbba38b5ddcbd5d4882e715a9e008061ab6d2ec432ef7b",
"sha256:47b6660a2b9bb2091a980c361a1c15b2a391c530877961877138fffc50d2f6f7",
"sha256:1e69483976e43c657172c073712c3d741245f9141fb560db5af7704aee95114c",
"sha256:a51886a0928017dc307b85b85c6fb3f9d727b5204ea944117ce59c4b4acc5e05",
"sha256:ba9804f7abedf8ddea3228539ae12b90966272d6eb02fd2c57446e44d32f1b70",
"sha256:c77311ff502e318598cc7b6c03a5bd25182f7f6f0352d9b68fad82ed7b4e8c26",
"sha256:93a6676fffe0813e4ca03cae4769300b539b66234e95be66d86c3ac7748c1321",
"sha256:3cf3c6f03984f8a31c0181feb75ac056fc2bd56ef8282af9a72dafd1b6bb0c41",
"sha256:02dacaf7071cc5792c28a4cf54141b7058ee492c93f04176f8f3f090c42735eb",
"sha256:85152f012a08f63dfaf306c01ac382c1962871bf1864b357549899ec2fa7385d",
"sha256:8ceb0bd5afef8a5fa451f89273974732cd0c89dac2c80ff8b7855531710fbc49"
]
(...)
On peut y voir un bloc de configuration avec notamment :
- Les variables d’environnements
- La commande d’entrypoint, la commande par défaut
- Le répertoire de travail
- L’utilisateur de l’image
Et un deuxième bloc “Layers” (couches) avec une liste de checksums. Chaque checksum correspond à un fichier archive compressé (.tar.gz). Toutes ces couches appliquées les unes sur les autres construisent un filesystem complet. Pour en savoir plus ce sujet je vous invite à lire l’article de David.
Comment Docker construit une image
Pour comprendre comment Docker fabrique une image, il est nécessaire de connaître la commande docker commit
. Cette commande se lance sur un container en train de s’exécuter. Elle va créer une image à partir de l’état de ce container.
C’est ce mécanisme qui est utilisé par exemple dans notre instruction RUN npm install
:
- Un container intermédiaire est lancé par Docker.
- Dans ce container, on lance la commande
npm install
- Une fois la commande terminé, Docker commit le container, la différence entre l’image précédente permet d’obtenir une couche supplémentaire et donc une nouvelle image intermédiaire.
docker build
a cependant un inconvénient important : par défaut, le build n’est pas reproductible. Cela signifie que deux docker build
successifs, avec le même Dockerfile, ne produiront pas nécessairement les mêmes couches, et donc les mêmes checksums. Ce qui va être à l’origine de ce phénomène est majoritairement les timestamps. Chaque fichier dans un filesystem Linux standard sera accompagné d’une date de création, de dernière modification et de dernier accès. Egalement, l’image possède un timestamp qui est intégré à l’image et altère le checksum. Cela provoque une grande difficulté pour isoler nos différentes couches, et il est difficile d’associer logiquement une opération de notre Dockerfile à une couche dans notre image finale.
Cela signifie que pour la modification de l’image de base (de l’instruction FROM
, pour des raisons de sécurité par exemple), Docker va nécessairement lancer un build complet. Egalement, l’envoi de cette mise à jour sur notre registry implique d’envoyer l’intégralité des couches. Selon la taille de notre image, cela peut grimper à un trafic important et ce sur chacune de nos machines hébergeant notre image et souhaitant se mettre à jour.
Une image n’est simplement qu’un empilement de couches les unes sur les autres, ainsi que des fichiers de configurations. Cependant Docker (et son Dockerfile) n’est pas la seule manière de construire une image. Buildpacks offre un principe différent pour la construction d’images. Buildpacks est un projet incubé à la Cloud Native Computing Foundation.
C’est quoi UN buildpack ?
Un buildpack est un ensemble de script exécutables qui vont permettre de construire et de lancer notre application.
Un buildpack c’est 3 composants :
- buildpack.toml : les metadonnées de votre buildpack
- bin/detect : script qui détermine si le buildpack s’applique à votre application
- bin/build : script lançant la séquence de construction de notre application
Construire son application, c’est ‘exécuter’ des buildpacks les uns à la suite des autres.
Pour cela on utilise un builder. Un builder est une image comprenant tout un ensemble de buildpacks, un cycle de vie et une référence à une image de run très légère dans laquelle on va intégrer notre application. Le couple image de build/image de run est appelé une stack.
Pour revenir à notre application Node.js, Buildpack ne se servira que des informations du package.json
.
{
"name": "myapp",
"version": "0.1",
"main": "index.js",
"license": "MIT",
"dependencies": {
"express": "^4.18.1"
},
"scripts": {
"start": "node src/index.js"
},
"engines": {
"node": "16"
}
}
C’est tout ce dont nous avons besoin pour construire une image contenant notre application avec buildpacks :
- L’image de base (le
FROM
du Dockerfile) sera celle spécifiée dans la stack - Le cycle de vie de Buildpacks détecte un
package.json
et donc lance la procédure d’installation de Node.js et des dépendances. - La version de node sera la version spécifiée dans le
package.json
- La commande par défaut sera
node src/index.js
car il s’agit du ‘start’ dupackage.json
La seule commande qu’il est nécessaire de connaître pour utiliser buildpacks est la suivante :
pack build myapp --builder gcr.io/buildpacks/builder:v1
Ici nous utilisons le builder fourni par Google ‘gcr.io/buildpacks/builder:v1’. D’autres builders sont disponibles (voir pack stack suggest
) ou vous pouvez construire le vôtre !
Il est ensuite directement possible de lancer notre application.
$ docker run myapp
> myapp@0.0.1 start
> node src/index.js
Example app listening on port 3000
Les avantages d’utiliser buildpacks sont notamment :
- L’absence de Dockerfile. Le développeur peut se concentrer sur son code uniquement.
- Le respect des bonnes pratiques de construction d’images, en terme de sécurisation, limitation du nombre et de la taille des couches. Cette charge revient au concepteur du buildpack, pas au développeur.
- La gestion de cache est native, que ce soit les binaires à installer ou les bibliothèques logicielles.
- Utilise les bonnes pratiques de votre organisation si vous créez votre propre buildpack (utilisation de miroirs internes, analyse de code…)
- Chaque couche de l’image finale est lié logiquement avec les dépendances qu’elle apporte.
- Chaque opération d’un buildpack ne touche qu’à un périmètre restreint (un dossier portant le nom du buildpack dans l’image de run)
- Buildpacks permet d’obtenir des builds reproductibles à moindre effort.
- Les fichiers copiés dans l’image ont tous un timestamp avec une valeur de 0.
- Il est par contre nécessaire que les étapes de build intermédiaires (compilation de code Java par exemple) doivent être idempotentes.
- Cela permet de lancer une opération que buildpacks appelle le ‘rebase’.
Le rebase d’image avec Buildpacks
Le rebase d’image consiste à pouvoir échanger plusieurs couches d’une image, sans nécesiter de modifier les couches supérieures. Cela est particulièrement pertinent dans le cadre de la modification de la base image de notre image de run. Prenons un exemple d’une application Node.js (que je simplifie grossièrement)
Au moment du build, Buildpacks se base sur une image de run la plus légère possible, ajoutant couche après couche :
- Une couche dans laquelle se trouve les binaires node
- Une couche dans laquelle se trouve les
node_modules
(dépendance) de notre application
On notera que le binaire npm n’est pas nécessaire dans notre image de run. Il ne sera donc pas inclus, mais il sert dans notre image de build pour installer les node_modules
qui seront eux intégrés dans notre image de run.
Dans l’hypothèse où notre container est deployé dans Kubernetes et qu’une faille soit détectée dans son image de base, il est nécessaire de la mettre à jour. Avec Docker cela nécessiterait de rebuilder notre image complètement, d’envoyer sur notre registry l’intégralité de notre nouvelle image, et chaque worker Kube devra télécharger cette nouvelle image.
Ce n’est pas nécessaire avec Buildpacks, seules les couches relatives à l’image de base doivent être téléchargés. Ici nous pouvons comparer deux images OCI, l’une avec une version ubuntu:18.04, l’autre ubuntu:20.04. Les 3 premières couches (en bleu) sont les couches relatives à Ubuntu. Les couches suivantes (en rouge), sont les couches ajoutés par Buildpacks : ces dernières sont identiques. Ce comportement est possible dans Docker (mais complexe à mettre en place), il l’est par défaut avec Buildpacks.
Limitations
Buildpacks vient avec une limitation importante : un buildpacks ne peut toucher qu’à un périmètre très restreint du système de fichiers. Les librairies et binaires doivent être installés dans le dossier du buildpacks qui les installe. Cela permet une séparation claire des périmètres de chaque buildpacks mais demande une plus grande rigueur. Il n’est donc plus possible de simplement lancer des installations via le gestionnaire de paquets (apt, yum ou apk). Si il n’est pas possible de passer outre cette limitation, il est nécessaire de modifier l’image de la stack.
Conclusion
Si vous souhaitez mettre en place facilement des bonnes pratiques de création d’image pour vos applications containerisées, Buildpacks est un excellent choix à considérer. Il s’intègre facilement à votre CI/CD via des projets comme kpack. Il est déjà même peut-être intégré à votre infrastructure DevOps car c’est Buildpacks qui se cache derrière la fonctionnalité Auto DevOps de Gitlab.