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 en environnement reproductible d’un run à l’autre. 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 mise en œuvre.

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 vienne 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, une archive tar.

Seuls les fichiers présents dans le contexte et les résultats de l’exécution de commandes peuvent être inclus dans l’image docker.

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 script 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é à 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écessaire à 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ôt 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 ceertains 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. Utiliser dive pour analyser les images.
  9. 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.

Installation et configuration de Docker

Linux

Voir le wiki du SI.

Notez bien que chaque commande docker doit être préfixée par sudo3 sauf si votre utilisateur a été ajouté au groupe docker.

Windows

Certaines personnes à Systerel ont réussi à utiliser docker avec WSL2 en utilisant ces deux tutoriels :

PS C:\Windows\system32> wsl -l -v
  NAME      STATE           VERSION
Ubuntu    Running         1
PS C:\Windows\system32> wsl --set-version Ubuntu 2
La conversion est en cours. Cette opération peut prendre quelques minutes...
Pour plus dinformations sur les différences de clés avec WSL 2, visitez https://aka.ms/wsl2
La conversion est terminée.

Utilisation d’une image docker

docker run

La commande principale pour exécuter une image docker est docker run. Par exemple, pour lancer l’image docker qui affiche Hello from Docker! :

$ docker run hello-world

Les options couramment utilisées sont :

  • --rm pour détruire le conteneur une fois qu’il a été exécuté
  • -it pour garder STDIN ouvert et allouer un pseudo-terminal et permettre une session interactive,
  • -v pour monter un volume local dans le conteneur (par exemple, -v $(pwd):/data).

docker exec / docker ps

Si l’image exécute un binaire qui ne se termine pas, il est possible de lancer une commande dans ce conteneur avec docker exec.

Par exemple si on a lancé un conteneur alpine avec un shell qui attend une commande :

$ docker run --rm -it --name=calpine alpine sh

On peut voir que le conteneur est en train d’être exécuté avec la commande docker ps.

$docker ps   
CONTAINER ID   IMAGE     COMMAND   CREATED         STATUS         PORTS     NAMES
ec551687cc1f   alpine    "sh"      9 minutes ago   Up 9 minutes             calpine

On peut lancer dans un autre terminal :

$ docker exec calpine ls |  head 
bin
dev
etc
home
lib
media
mnt
opt
proc
root

Autres commandes couramment utiles

  • docker cp pour copier des fichiers entre le conteneur et le système de fichier local,
  • docker images pour lister les images disponibles dans le cache local,
  • docker rmi pour supprimer une ou plusieurs images du cache local,
  • docker system prune pour supprimer les images non référencées ou toutes les images inutilisées.
  • docker export/docker load pour sauvegarder et importer une image dans ou depuis une archive tar. Parfois utile pour copier une image sur une machine qui n’a pas accès à un dépôt d’image.

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. Il est aussi possible d’utiliser sudo -g docker pour éviter de passer root

Commentaires