Construction d’image de container avec Kaniko

Yannig Perré
10 min readDec 27, 2019

Disclaimer : ce qui va suivre est un retour d’expérience sur l’utilisation de Kaniko par rapport à mon utilisation de Docker. Si vous voyez des erreurs/incohérences, n’hésitez pas à m’en faire part.

Note : cette note est également disponible en anglais en suivant ce lien.

Il y a peu de temps, mon équipe a démarrée un nouveau projet. Dès le début notre choix s’est porté sur l’utilisation de worker Gitlab déployé dans Kubernetes pour la construction des images.

Auparavant, cette construction s’appuyait sur les démons Docker de nos machines Kubernetes. Pour cela, il suffisait d’exposer le point de contrôle du démon au pod (/var/run/docker.sock) en charge de la compilation pour pouvoir lancer la construction.

Néanmoins, cette démarche pose plusieurs problèmes :

  • Utilisation de Docker pour la compilation alors que sa présence n’est pas obligatoire (nous pourrions utiliser un autre gestionnaire de container)
  • Contourne l’affectation de ressources de Kubernetes (pas glop)
  • Introduction d’une faille de sécurité du fait de la manipulation direct du démon Docker sous-jacent (pas glop non plus)
  • Les workers Gitlab sont — par défaut — incompatible avec ce mécanisme

Afin de répondre à ces problèmes, nous avons commencé à expérimenter Kaniko. Cet outil permet de construire des images de conteneurs à partir d’un Dockerfile tout en restant à l’intérieur d’un conteneur (dans un cluster Kubernetes) et ne change rien — en principe — sur le déroulement de la procédure de compilation.

Dans ce qui va suivre, nous allons essayer de quantifier cela et voir les contournements/optimisations que nous avons mis en œuvre.

Image de test

Pour les premiers tests, nous allons faire appel au fichier Dockerfile suivant :

FROM node:12 AS build
RUN echo "hello" > /result.txt
RUN cat /result.txt
FROM node:12
COPY --from=build /result.txt /

Il s’agit d’une image à étape multiple (multi-stage) qui reproduit les opérations suivantes :

  • Construction d’un livrable dans une image de build
  • Mise à disposition de la compilation dans une image minimale

Ce type de technique est utilisé pour permettre la compilation de programme dans Docker sans pour autant alourdir une image avec un compilateur et éviter de laisser traîner des outils susceptibles d’introduire des problèmes de sécurité.

Test de compilation avec Docker

En premier lieu, placez-vous dans le même répertoire du fichier Dockerfile.

Préchargez ensuite l’image node:12 à l’aide de la commande suivante :

$ docker pull node:12

Lancez maintenant la création de l’image à l’aide de la commande suivante précédé de la commande time afin de connaître le temps d’exécution de la construction :

$ time docker build .

Cette opération devrait prendre entre 3 et 5 secondes:

Sending build context to Docker daemon 24.58kB
Step 1/5 : FROM node:12 AS build
— -> 5ad9a7363da8
Step 2/5 : RUN echo “hello” > /result.txt
— -> Running in 0cab570944f1
Removing intermediate container 0cab570944f1
— -> 412ad7de7cc5
Step 3/5 : RUN cat /result.txt
— -> Running in 5a4c71d110ee
hello
Removing intermediate container 5a4c71d110ee
— -> 888bdd7fded5
Step 4/5 : FROM node:12
— -> 5ad9a7363da8
Step 5/5 : COPY — from=build /result.txt /
— -> 00291feb766e
Successfully built 00291feb766e
real 0m3,131s
user 0m0,044s
sys 0m0,020s

Lancez une seconde fois l’opération de compilation :

$ time docker build .

Cette fois-ci, l’opération devrait prendre entre 200 et 500 ms :

Sending build context to Docker daemon 33.28kB
Step 1/5 : FROM node:12 AS build
— -> 5ad9a7363da8
Step 2/5 : RUN echo “hello” > /result.txt
— -> Using cache
— -> 412ad7de7cc5
Step 3/5 : RUN cat /result.txt
— -> Using cache
— -> 888bdd7fded5
Step 4/5 : FROM node:12
— -> 5ad9a7363da8
Step 5/5 : COPY — from=build /result.txt /
— -> Using cache
— -> 00291feb766e
Successfully built 00291feb766e
real 0m0,198s
user 0m0,011s
sys 0m0,041s

La différence de temps d’exécution s’explique par le fait que Docker a utilisé un cache pour éviter de lancer certaines étapes (on le voit au niveau des lignes contenant le message ---> Using cache).

Compilation avec Kaniko

Maintenant que le temps de construction sous Docker est connu, lancez la procédure de construction avec la même image avec Kaniko.

En soit, Kaniko fonctionne très bien depuis un démon Docker. L’image à utiliser porte le nom gcr.io/kaniko-project/executor:v0.15.0.

Note: Par défaut, il n’existe aucun shell dans l’image Docker. Pour en disposer d’un (ça peut être nécessaire dans le cas de Gitlab par exemple), vous pouvez faire appel à l’image gcr.io/kaniko-project/executor:debug-v0.15.0.

Afin de lancer Kaniko, vous allez devoir passer les options suivantes à la commande docker :

  • Lancer le container avec le verbe run
  • Disposer d’un terminal interactif avec les options -it
  • Supprimer le container à la fin de la commande --rm
  • Le nom de l’image à lancer (gcr.io/kaniko-project/executor:v0.15.0)

En l’état, la commande à lancer sera la suivante :

$ docker run -it --rm gcr.io/kaniko-project/executor:v0.15.0

Cette commande renverra alors les options d’exécution de Kaniko :

Usage:
executor [flags]
executor [command]
Available Commands:
help Help about any command
version Print the version number of kaniko
...

Afin de pouvoir lancer la construction de l’image, il va être nécessaire de monter le répertoire courant ($PWD) dans un répertoire /workspace du container (option -v suivi du chemin à monter et l’emplacement destination séparé par deux points :).

Ci-dessous la même commande avec le montage du répertoire courant dans le container :

$ docker run -it --rm -v $PWD:/workspace \
gcr.io/kaniko-project/executor:v0.15.0

Afin de procéder à la compilation de l’image, ajoutez les options suivantes :

  • L’emplacement de travail avec l’option -c suivi du chemin où se trouve l’image à construire (-c /workspace)
  • L’option --no-push afin d’indiquer que nous ne pousserons l’image nul part

Remplacez l’option --no-push par -d NOM_IMAGE:tag ou --destination NOM_IMAGE:tag pour pousser l’image sur le registre distant.

La commande complète permettant de tester la procédure sera donc la suivante :

$ time docker run -it --rm -v $PWD:/workspace \
gcr.io/kaniko-project/executor:v0.15.0 \
-c /workspace --no-push

En la préfixant avec la commande time la commande met environ 80 secondes à s’exécuter …

Ci-dessous un extrait du résultat de cette commande :

INFO[0000] Resolved base name node:12 to node:12         
INFO[0000] Resolved base name node:12 to node:12
INFO[0000] Resolved base name node:12 to node:12
INFO[0000] Resolved base name node:12 to node:12
INFO[0000] Downloading base image node:12
INFO[0001] Error while retrieving image from cache: getting file info: stat /cache/sha256:11a46719b3aa4314c39eb41e7638308c9c1976fac55bfe2a6cbb0aa7ab56a95f: no such file or directory
INFO[0001] Downloading base image node:12
INFO[0002] Downloading base image node:12
INFO[0003] Error while retrieving image from cache: getting file info: stat /cache/sha256:11a46719b3aa4314c39eb41e7638308c9c1976fac55bfe2a6cbb0aa7ab56a95f: no such file or directory
INFO[0003] Downloading base image node:12
INFO[0004] Built cross stage deps: map[0:[/result.txt]]
INFO[0004] Downloading base image node:12
INFO[0004] Error while retrieving image from cache: getting file info: stat /cache/sha256:11a46719b3aa4314c39eb41e7638308c9c1976fac55bfe2a6cbb0aa7ab56a95f: no such file or directory
INFO[0004] Downloading base image node:12
INFO[0005] Unpacking rootfs as cmd RUN echo "hello" > /result.txt requires it.
INFO[0139] Taking snapshot of full filesystem...
INFO[0143] RUN echo "hello" > /result.txt
INFO[0143] cmd: /bin/sh
INFO[0143] args: [-c echo "hello" > /result.txt]
INFO[0143] Taking snapshot of full filesystem...
INFO[0144] RUN cat /result.txt
INFO[0144] cmd: /bin/sh
INFO[0144] args: [-c cat /result.txt]
hello
INFO[0144] Taking snapshot of full filesystem...
INFO[0146] No files were changed, appending empty layer to config. No layer added to image.
INFO[0146] Saving file /result.txt for later use.
INFO[0146] Deleting filesystem...
INFO[0147] Downloading base image node:12
INFO[0148] Error while retrieving image from cache: getting file info: stat /cache/sha256:11a46719b3aa4314c39eb41e7638308c9c1976fac55bfe2a6cbb0aa7ab56a95f: no such file or directory
INFO[0148] Downloading base image node:12
INFO[0149] Unpacking rootfs as cmd COPY --from=build /result.txt / requires it.
INFO[0284] Taking snapshot of full filesystem...
INFO[0286] COPY --from=build /result.txt /
INFO[0286] Taking snapshot of files...
INFO[0286] Skipping push to container registry due to --no-push flag

Pourquoi est-ce si long

En réalité, le fonctionnement de Kaniko est très différent de celui de Docker pour construire son image. En effet, pour travailler, Docker s’appuie sur les images présentes sur la machine.

Dans le cas de Kaniko, ce dernier n’a pas accès aux images du démon Docker. Il doit donc récupérer ce contenu depuis le Docker registry ce qui ajoute inévitablement du temps et de la consommation réseau.

Or, quand on regarde la taille de l’image Docker node:12, cette dernière consomme énormément de place : 900Mo (décompressé)

Pour ne rien arranger, en plus de télécharger cette image, Kaniko doit également extraire l’image en local avant de pouvoir commencer à travailler.

Toutes ces opérations ralentissent le processus et prennent du temps.

Afin de réduire le temps de construction, nous allons essayer de tester plusieurs techniques.

Piste de réduction des temps de compilation

Afin de réduire les temps de compilation, plusieurs techniques peuvent être utilisées :

  • Utilisation d’image de base à empreinte réduite (version alpine par exemple)
  • Fusion des appels à RUN
  • Suppression des caches locaux suite au build
  • Activation d’un cache basé sur un registre d’image Docker

Image à empreinte réduite (aka Alpine flavor)

Première étape : tenter de réduire la taille des images utilisées. Lancez la commande suivante afin de connaître la taille de l’image node:12 :

$ docker image ls node:12

Cette commande devrait renvoyer les informations suivantes :

REPOSITORY     TAG   IMAGE ID       CREATED       SIZE
node 12 7be6a8478f5f 2 weeks ago 908MB

L’image fait ici environ 900 Mo (!?!).

Lancez maintenant la même commande sur l’image node:12-alpine :

$ docker image ls node:12-alpine

Ci-dessous les informations renvoyées :

REPOSITORY   TAG         IMAGE ID       CREATED       SIZE
node 12-alpine 3fb8a14691d9 2 weeks ago 80.2MB

L’image Alpine prend plus de 10 fois moins de place que l’image “standard” !

Afin d’utiliser cette image, remplacez les occurrences de node:12 par node:12-alpine.

Le Dockerfile devrait avoir la forme suivante :

FROM node:12-alpine AS build 
RUN echo "hello" > /result.txt
RUN cat /result.txt

FROM node:12-alpine
COPY --from=build /result.txt /

Relancez la procédure de compilation avec la commande suivante :

$ time docker run -it --rm -v $PWD:/workspace \
gcr.io/kaniko-project/executor:v0.15.0 \
-c /workspace --no-push

Cette fois-ci, l’opération dure environ 14 secondes.

Fusion des appels RUN

Pour bien comprendre le problème, nous allons prendre un exemple de lancement de commande tout simple se basant sur le téléchargement d’une image Alpine de base :

FROM alpine
RUN echo command1
RUN echo command2
RUN echo command3

Avec Kaniko, la construction prend environ 10 secondes.

Essayons une version optimisée pour voir les gains possibles :

FROM alpine
RUN echo command1 && \
echo command2 && \
echo command3

Cette fois-ci, la construction prend environ 7 secondes.

Ce gain s’explique par le fait qu’entre chaque appel à RUN, Kaniko na plus besoin d’extraire/inspecter le contenu du répertoire de travail.

Suppression des caches locaux dans l’image

Lors de la construction d’image (comme dans le cas de dépendances Javascript Yarn, npm, gradle ou Maven), le processus peut rapidement se retrouver à devoir télécharger plusieurs milliers de fichiers (notamment au travers le contenu du répertoire node_modules pour des dépendances Javascript).

La construction se retrouve donc accompagnée de ces fichiers qui peuvent prendre plusieurs centaines de Mo. Cet état de fait va avoir deux conséquences :

  • ralentir le lancement des builds
  • augmenter la taille des builds résultants

Afin de réduire cet impact, les répertoires de cache doivent être supprimés (avec la commande rm -rf node_modules dans le cas des librairies Javascript) ou par le lancement d’une purge (apt clean all par exemple suite à l’installation d’un package).

Autre aspect important, les commandes doivent absolument être enchaînées après les opérations de construction (comme vu précédemment). En effet, si ce n’est pas fait à ce moment là, Kaniko stockera une image intermédiaire qui contiendra l’ensemble de ces fichiers et qui aura pour conséquence de ruiner les efforts de réduction de taille des images.

Ci-dessous un exemple de commande respectant ces directives :

RUN yarn --frozen-lockfile && \
yarn build && \
yarn cache clean && \
rm -rf node_modules

Activation cache par registre Docker

Dernier aspect permettant de gagner du temps lors du lancement des différentes opérations : l’utilisation d’un cache.

En effet, contrairement à Docker qui est en mesure de stocker les différentes étapes de build, Kaniko n’a aucun moyen de le faire et perd son contexte à chaque fois.

Afin de conserver le résultat des opérations lancées, ce dernier peut faire appel à un registre Docker. Cette activation se fait à l’aide des options suivantes :

  • Activation du cache avec l’option --cache
  • Spécification optionnel d’un emplacement de registre qui servira de cache avec l’option --cache-repo

La seconde option n’est pas obligatoire mais permet de spécifier un emplacement différent pour stocker les couches intermédiaires qui serviront de cache d’exécution.

A noter que dans le cas de l’utilisation d’un cache, il n’est plus possible de faire appel à l’option --no-push. Vous devrez donc spécifier le nom de l’image avec l’option -d NOM_IMAGE:tag.

La seule difficulté vient du fait qu’il est nécessaire de disposer de droits pour pousser sur ce registre et donc d’être authentifié. Malheureusement, Kaniko n’a pas d’options permettant de réaliser cette authentification. Pour contourner le problème, vous devez alimenter le contenu du fichier /kaniko/.docker/config.json dans l’image Kaniko.

Ci-dessous un exemple de tâche dans Gitlab permettant de réaliser cette opération :

my-build:
image:
name: gcr.io/kaniko-project/executor:debug-v0.15.0
entrypoint: [""]
before_script:
- echo "{\"auths\":{\"$CI_REGISTRY\":{\"username\":\"$CI_REGISTRY_USER\",\"password\":\"$CI_REGISTRY_PASSWORD\"}}}" > /kaniko/.docker/config.json
script:
- /kaniko/executor --cache -c $CI_PROJECT_DIR -d $CI_REGISTRY_IMAGE/my-image:$CI_COMMIT_REF_NAME

Ici, il s’agit de générer un fichier de configuration Docker permettant la connexion au registre d’images.

Dans le cas d’une compilation de test en local et que vous êtes déjà connecté (avec docker login par exemple), vous pouvez exposer le contenu de votre répertoire ~/.docker dans le container de Kaniko. Cette opération se fait en ajoutant l’option -v $HOME/.docker:/kaniko/.docker.

Ci-dessous un exemple de lancement avec toutes ces options :

$ docker run -it --rm -v $PWD:/workspace \
gcr.io/kaniko-project/executor:v0.15.0 \
-c /workspace --no-push

Pour aller plus loin

Globalement, ce projet est encore jeune et souffre de quelques défauts :

  • Lenteur de compilation (par rapport à une compilation Docker classique)
  • Bugs de comportement vis à vis de Docker (notamment avec la gestion du cache en version 0.14)

La bonne nouvelle est que les choses s’améliorent et qu’il est possible d’avoir des temps de compilation acceptables moyennant quelques astuces.

De mon point de vue, il peut s’agir d’une bonne occasion de repenser la construction de ses images afin d’aller à l’essentiel et de procéder au maximum à des simplifications.

--

--

Yannig Perré

Sysadmin for many years in France, I’m working at AIOS (http://aios.sh). I’m a big fan of Prometheus, Kubernetes and Ansible.