Conteneurs et Docker

La technique de « conteneurisation » a de nombreux avantages. Elle est notamment souvent utilisée dans le cadre de l’intégration continue pour sa capacité à définir un environnement reproductible d’un run à l’autre, quelle que soit la machine sur laquelle elle est exécutée. Cet article se propose d’exposer les intérêts et limites de cette technique dans le cas de Docker et de donner des bonnes pratiques de création d’images de conteneurs.

Conteneur ou machine virtuelle ?

La technologie de conteneurisation s’appuie sur les fonctionnalités du noyau linux (principalement les cgroups et les espaces de nom) pour fournir une exécution dans un espace isolé. Le conteneur est plus léger que la machine virtuelle : il repose sur les services offerts par l’OS de la machine physique et n’inclut pas de noyau. En général, il n’inclut qu’une application, avec les fichiers nécessaires à son exécution. Sa taille se compte plutôt en mégaoctets là où les machines virtuelles pèsent fréquemment plusieurs gigaoctets. En effet, ces dernières intègrent un système d’exploitation complet et exécutent souvent plusieurs fonctions.

Docker vs VM

Les conteneurs sont aussi beaucoup plus légers et rapides à démarrer qu’une machine virtuelle 1.

Ils sont aussi utilisables dans des contextes d’orchestration de conteneurs tels que Kubernetes, qui permettent de gérer, déployer et faire monter en capacité des applications en conteneurs.

Terminologie

On parlera de :

  • conteneur (container) : un groupe de processus isolé des autres processus créé à partir d’une image de conteneur.
  • image de conteneur ou container image : le fichier contenant une image du système de fichiers utilisé pour démarrer le conteneur

Images de conteneurs

Une image de conteneur est un fichier qui contient l’ensemble des éléments permettant de créer un conteneur une fois exécuté sur le runtime. Il existe plusieurs logiciels permettant d’exécuter des images de conteneurs, avec des objectifs et fonctionnalités différentes : le plus utilisé est docker engine, mais il y a aussi podman, containerd, kubernetes, rancher,…

Le format des images est défini par une spécification ouverte gérée par l’Open Container Initiative. Une image est essentiellement constituée de métadonnées et d’une ou plusieurs couches de système de fichiers (layers) qui une fois empilées viennent constituer le système de fichier visible des applications qui s’exécutent dans le conteneur.

Une image est construite à partir d’un fichier de description. À chaque ligne du fichier correspond une couche de système de fichiers.

La figure suivante montre une image construite à partir d’une image de base ubuntu 15.04 à laquelle on ajoute le répertoire courant dans /app, puis on lance la commande make et enfin on positionne la commande par défaut du conteneur comme étant app.py.

Lien entre Dockerfile et l’image construite

L’outil Dive permet de visualiser le contenu d’une image, de parcourir les différentes layers et de mesurer l’éventuel espace gâché.

Capture d’écran de l’utilisation de Dive

Fabrication d’une image docker

Pour fabriquer une image docker, on fournit à l’outil un fichier de description Dockerfile et un contexte composé :

  • de l’ensemble des fichiers contenu dans un répertoire (certains fichiers pouvant être ignorés au moyen d’un fichier .dockerignore, à l’image du .gitignore)
  • ou d’une URL pointant vers un dépot git2,
  • ou d’une archive tar.

Les fichiers présents dans le contexte pourront être inclus dans l’image au moyen des commandes COPY ou ADD.

Le format du Dockerfile est documenté. Les principales directives sont :

  • FROM pour indiquer l’image de base,
  • COPY ou ADD pour ajouter des fichiers ,
  • RUN pour lancer une commande dans un shell,
  • ENV pour positionner des variables d’environnement,
  • ARG pour définir des variables qui seront positionnables sur la ligne de commande du docker build
  • ENTRYPOINT qui définit la commande qui sera exécutée au démarrage,
  • CMD qui définit les paramètres de la commande exécutée au démarrage,
  • EXPOSE qui expose un port à l’extérieur du conteneur.

Un exemple simple de Dockerfile pour dockeriser un script python :

# ./Dockerfile
FROM python:3
RUN pip install pydependency
ADD my_script.py /
CMD [ "python", "./my_script.py" ]

L’image nommée script:latest se construit dans le répertoire contenant le Dockerfile et le script my_script.py avec :

docker build -t script:latest .

Pour pousser cette image sur un dépôt d’images, il faut la tagguer avec l’URL de ce dépôt et la téléverser avec la commande docker push :

docker tag script:latest docker.aix.systerel.fr/cXXX/script:latest
docker tag script:latest docker.aix.systerel.fr/cXXX/script:1.3
docker push docker.aix.systerel.fr/cXXX/script:latest
docker push docker.aix.systerel.fr/cXXX/script:1.3

Cache docker

Chaque ligne du Dockerfile est associée à une couche (layer) dans le cache de l’outil Docker qui permet d’accélérer les reconstructions d’images, ce qui est bien pratique notamment lors de la mise au point des images.

Lors d’une nouvelle construction de l’image à l’aide de la commande docker build, si la ligne est identique à celle qui a produit la couche associée dans le cache, alors la couche du cache est réutilisée telle quelle. Pour les commandes COPY et ADD le checksum des fichiers copiés est comparé au checksum des fichiers utilisés précédemment pour décider s’il faut utiliser le cache ou non.

Dans l’exemple ci-dessus, ça veut dire que la couche correspondant à RUN pip install pydependency ne sera pas reconstruite (et qu’une nouvelle version de pydependency ne sera pas installée sans invalidation du cache). Il est possible d’utiliser l’option --no-cache=true pour forcer docker build à ne pas utiliser le cache.

Dockerfile Multi-stages

Une des difficultés pour la construction des images docker est de limiter leur taille. Si on ne prend pas garde, on peut vite arriver à une taille de plusieurs centaines de Mo en partant d’une image de base et en ajoutant les chaînes de compilation nécessaires à l’obtention du binaire que l’on souhaite exploiter dans l’image.

Une solution est d’utiliser une fabrication en deux étapes afin de ne conserver que le strict nécessaire dans l’image finale :

  1. une première image docker contient tout ce qui est nécessaire à la compilation et est instanciée en un conteneur produisant le binaire à partir des sources,
  2. l’image finale inclut les dépendances indispensables au runtime et le binaire copié depuis le conteneur précédent.

Concrètement cela se fait de la manière suivante, en utilisant l’option --from de la directive COPY pour récupérer un fichier depuis le conteneur de fabrication :

# ./Dockerfile
FROM golang:1.16 AS builder
WORKDIR /go/src/github.com/alexellis/href-counter/
RUN go get -d -v golang.org/x/net/html  
COPY app.go ./
RUN CGO_ENABLED=0 go build -a -installsuffix cgo -o app .

FROM alpine:latest  
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=builder /go/src/github.com/alexellis/href-counter/app ./
CMD ["./app"]

Dépôt d’image (container registry)

Plusieurs dépôts d’image de conteneurs sont disponibles :

  • Docker Hub, le dépôt officiel de la société Docker (avec une limite au nombre d’images lues ! Ça peut être source de mauvaises surprises). C’est le dépôt utilisé par défaut quand on ne spécifie pas l’URL complète d’une image.
  • Quay Container Registry hébergé par RedHat.
  • Systerel Docker Registry, le dépôt interne à Systerel.
  • Les dépôts spécifiques à chaque projet sur le gitlab interne ou externe (par exemple : le dépôt pour le projet S2OPC)

Image de base

L’image de base est celle qui est référencée par la directive FROM du Dockerfile. Plusieurs images sont généralement utilisées :

  • debian slim qui pèse 28 Mo (50 Mo pour la version normale),
  • alpine qui pèse moins de 3 Mo, mais qui utilise musl comme librairie C, ce qui peut parfois compliquer l’installation de certains programmes,
  • distroless qui est destinée à contenir le binaire de l’application et ses dépendances à l’exclusion de tout autre binaire.
  • python avec des déclinaisons à base d’image debian ou alpine,
  • golang pour la compilation des langages en Go avec des déclinaisons à base d’image debian ou alpine,

Multi-Architectures

Une image docker peut contenir des variants pour différentes architectures. Par exemple, l’image busybox sur le docker hub supporte les architectures suivantes amd64, arm32v5, arm32v6, arm32v7, arm64v8, i386, ppc64le, et s390x.

Référez vous à la documentation pour construire une image multi-plateformes.

Fabrication en CI

Il est parfois intéressant d’automatiser la création d’une image docker en intégration continue, mais cela nécessite l’utilisation de docker dans docker ce qui n’est pas toujours possible en fonction de la manière dont les runners sont configurés. L’outil kaniko permet de contourner ce problème puisqu’il n’a pas besoin dun démon docker pour construire des images. Voici un exemple simplifié d’utilisation (voir l’exemple complet en fin d’article) :

# .gitlab-ci.yml
variables:
  IMAGE: gitlab.aix.systerel.fr:5000/produit/s3/blast
docker:
  stage: docker
  image:
    name: gcr.io/kaniko-project/executor:debug
    entrypoint: [""]
  script:
    - /kaniko/executor --context $CI_PROJECT_DIR/docker/blast
      --build-arg IMAGE="${DEV_IMAGE}"
      --dockerfile $CI_PROJECT_DIR/docker/blast/Dockerfile
      --destination "${IMAGE}:${CI_COMMIT_TAG}"
      --destination "${IMAGE}:latest"
  only:                                                                         
    - /^\d+\.\d+.*$/ 
    - /^testdocker$/

Bonnes pratiques

Voici une liste de pratiques recommandées pour la création des images docker :

  1. Ajouter seulement les fichiers nécessaires (attention aux applications qui utilisent des caches, qui pourraient être embarqués dans l’image).
  2. Utiliser .dockerignore pour limiter le contexte au minimum requis.
  3. Forcer la mise à jour de l’image de base pour s’assurer d’avoir la dernière version (avec les derniers correctifs de sécurité) : docker build --pull .
  4. Éviter de dupliquer les fichiers, par exemple en utilisant l’option --chmod de la directive COPY pour copier un fichier avec les bonnes permissions en une seule étape.
  5. Configurer le gestionnaire de paquet en non interactif :
    • ENV DEBIAN_FRONTEND=noninteractive
    • RUN pip install --yes --no-input
  6. Mettre à jour les dépôts de paquets et l’OS de base (au détriment de la capacité à avoir une image reproductible, mais avec un gain de sécurité) :
    • RUN apt-get update && apt-get -y upgrade
    • RUN apk update && apk upgrade
  7. Configurer les gestionnaires de paquets pour ne pas utiliser le cache et installer le minimum de paquets :
    • RUN apk add --no-cache PACKAGE pour ne pas utiliser de cache sous alpine,
    • RUN apt-get -y --no-install-recommends install PACKAGE && rm -rf /var/lib/apt/lists/* pour ne pas installer les paquets optionnels et pour effacer le cache sous debian ou ubuntu,
    • RUN npm install --production pour ne pas installer les dépendances spécifiques au développement,
    • RUN pip install --no-cache-dir pour ne pas utiliser de cache.
  8. Configurer votre application hébergée dans le conteneur pour qu’elle écoute sur l’adresse générique 0.0.0.0 et non pas sur 127.0.0.1 ou localhost. Dans ce dernier cas, le port ne serait pas visible de l’extérieur du conteneur.
  9. Utiliser dive pour analyser les images.
  10. Utiliser un outil permettant de détecter les problèmes ou failles de sécurité, comme trivy

À explorer

Une alternative que nous n’avons pas encore mise en œuvre serait de créer et maintenir des images à jour en utilisant l’outil apko pour construire rapidement des petites images reproductibles uniquement à partir d’une liste de paquets apk (ce qui implique de construire les apk spécifiques à notre application).

Leur dépôt contient le résultat d’une analyse comparative et régulière des failles contenues dans les images publiques qui montre qu’il est possible de maintenir des images avec très peu de failles.

Volumes

Il est possible de déclarer dans le Dockerfile un ou plusieurs volumes :

FROM ubuntu
RUN mkdir /data
VOLUME /data

Le démon docker s’occupe alors de créer un espace dans le système de fichier hôte, d’y copier les données du répertoire de l’image docker et de monter cet espace sur le chemin spécifié (ici /data). Il est également possible lors de l’appel de docker run de spécifier un point de montage différent du volume sur le système hôte (bind mount).

docker run --rm -it -v /srv/app/data:/data:rw myubuntu:latest

Utiliser des volumes géré par le démon docker peut-être plus intéressant que des montages sur le système de fichier (bind mounts) notamment pour des raisons de performance sur Windows ou MacOs.

Installation et utilisation de docker

Ce sujet fera l’objet du billet sur l’Utilisation de Docker.

Interrogation d’un dépôt d’images

L’outil crane permet d’interroger ou de modifier des images distantes. Par exemple, pour connaître les images disponibles sur le dépôt interne3 :

$ docker run --rm -it gcr.io/go-containerregistry/crane:debug --insecure catalog docker.aix.systerel.fr
argos/gss
bullseye-min
buster-min
c462/sysdoc
c541/build
c541/pyinstaller
c672/mass-s3-build
…

Et pour lister les tags d’une image :

$ docker run --rm -it gcr.io/go-containerregistry/crane:debug --insecure ls docker.aix.systerel.fr/c939/reviewverif
0.5
latest

Exemples

Kaniko pour CI Gitlab

Exemple complet de configuration de la CI gitlab pour construire un conteneur.

variables:
  IMAGE: gitlab.aix.systerel.fr:5000/produit/s3/blast

docker:
  stage: docker
  image:
    name: gcr.io/kaniko-project/executor:debug
    entrypoint: [""]
  script:
    - echo "{\"auths\":{\"$CI_REGISTRY\":{\"username\":\"$CI_REGISTRY_USER\",\"password\":\"$CI_JOB_TOKEN\"}}}" > /kaniko/.docker/config.json
      # Add systerel certificate
    - |
      echo "-----BEGIN CERTIFICATE-----
      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
      -----END CERTIFICATE-----" >> /kaniko/ssl/certs/ca-certificates.crt
    - /kaniko/executor --context $CI_PROJECT_DIR/docker/blast
      --build-arg IMAGE="${DEV_IMAGE}"
      --dockerfile $CI_PROJECT_DIR/docker/blast/Dockerfile
      --destination "${IMAGE}:${CI_COMMIT_TAG}"
      --destination "${IMAGE}:latest"
  only:                                                                         
    - /^\d+\.\d+.*$/ 
    - /^testdocker$/


  1. Bien qu’il existe maintenant des solutions de Micro-VM rapides, modulo quelques contraintes. 

  2. Il est recommandé d’éviter les liens git en production, sauf à spécifier explicitement la version ou le tag utilisé (en ajoutant #nom_du_tag à l’URL en git://). 

  3. Dans le cas de ce dépôt, une interface web est disponible qui permet d’en afficher le contenu : https://docker.aix.systerel.fr/, mais ce n’est pas tout le temps le cas. 

Commentaires