owned this note changed 5 years ago
Published Linked with GitHub

Requirements:

  • avoir docker et docker-compose Explications ici :)
  • avoir testé son installation:
    • docker run hello-world (pas de sudo (c'est relou mais sécurisé))
    • docker-compose --version

Le déroulement du TP

Le TP va se dérouler en deux étapes, un peu comme les TPs de l'ING1 : il y aura une partie de cours/TD et un exercice à faire à la fin, à rendre avant le 8/11/2020 à 23h42.

Cours

Docker

Selon la firme de recherche sur l'industrie 451 Research, « Docker est un outil qui peut impacter une application et ses dépendances dans un conteneur isolé, qui pourra être exécuté sur n'importe quel serveur ».

wikipedia

Qu'est ce que docker et les conteneurs ?

Docker est un outil permettant de faciliter le développement et le déploiement de conteneur. Un conteneur est une enveloppe virtuelle qui permet de distribuer une application avec tous les éléments dont elle a besoin pour fonctionner : fichiers sources, environnement d'exécution, librairies, outils et fichiers. Il est tout à fait possible de réaliser des conteneurs sans l'aide de docker ou autres outils de ce type. Les conteneurs sont une forme de virtualisations qui est intégrée au Kernel Linux. Contrairement aux Machines Virtuelles qui virtualisent un OS entier, les conteneurs vont utiliser l'OS sur lequel ils sont lancés afin de limiter l'impact sur les performances. Les applications à l'intérieur des conteneurs sont "sur" l'OS mais sont séparées du reste des processus par divers moyens comme les namespaces.

Étant donné que les conteneurs contiennent tout ce qui est nécessaire pour leurs usages (en général une application), ils permettent de limiter le besoin de configuration nécessaire afin de faire tourner ladite application. En effet, il suffit de télécharger l'image, d'avoir un outil comme docker pour la lancer et rien de plus, contrairement à une application directement sur l'OS qui pourrait nécessiter l'installation de plusieurs dépendances. Ces dépendances sont souvent installées via un package-manager ce qui rend l'étape de configuration propre à un système et peu portable. Les conteneurs permettent donc de s'affranchir de toutes ces complications. Cela a aussi pour effet d'améliorer le temps de déploiement d'une application.

A noter que docker n'est pas le seul outil de ce type, on peut aussi citer LXC et Podman par exemple.

Pourquoi faire de la conteneurisation ? (avec Docker dans notre exemple)

Les conteneurs sont principalement utilisés car ils permettent d'avoir un environnement reproductible. Cela permet de faciliter le développement et les tests d'une application. Dans la majorité des cas, le comportement sera identique que ce soit sur le PC du développeur ou sur le serveur de production. Cela permet d'éviter les "Mais ça marche sur ma machine" lorsqu'il y a un problème en production.

Un développeur peut créer une image docker (nous verrons comment par la suite), cette image sera la même sur son PC et sur la production. Une image est versionnée, il est donc facile de revenir en arrière s'il y a le moindre soucis. Cette image pourra ensuite être partagée via un registry cela permet de rendre la rendre (et donc une application) disponible pour tout le monde très facilement.

De plus, un conteneur isole l'application du reste de l'OS, cela présente un avantage de sécurité si le conteneur est lancé avec les bons paramètres. Si un attaquant prend le contrôle de l'application, il ne pourra pas sortir du conteneur (sauf cas particulier), il ne pourra donc pas altérer les autres applications, ni le système.

Cet aspect de visualisation permet aussi de faire tourner plusieurs applications similaires sur un même OS. Ce qui n'est pas trivial avec des applications directement installées sur l'OS. Par exemple, si l'on veut plusieurs base de données PGSQL, cela serait moins évident car l'application se bind sur un port donné (par défaut 5432), on ne peut pas lancer 2 fois l'application sans changer la configuration d'au moins l'une des deux, cela n'est pas très pratique. Avec les conteneurs, il est très simple de changer le port sur lequel l'application va se bind sans modifier la configuration de l'application.

Comment faire du docker ?

Cycle de vie d'un produit dans Docker

Dans cette partie du TP, vous allez apprendre à faire du docker: comment créer le fichier Dockerfile et l'utiliser.

La première chose à savoir est que Docker utilise des fichiers appelés Dockerfile pour créer ses conteneurs.

Ces Dockerfile sont composés d'une suite d'instructions/commandes qui correspondent à toutes les commandes que voudrait faire un utilisateur pour créer une image.
Ces instructions sont de la forme:

INSTRUCTION argument

Lors du build de l'image, Docker va créer des couches(layers) pour chacune des instructions spécifiés.

Ces couches vont permettre d'optimiser les prochains build.
En effet, Docker ne va pas recréer les layers qui n'ont pas changés: par exemple, Docker ne va pas installer une dependence à chaque build, seulement lors du premier ou lorsque les dépendances ont changé (il est possible de le forcer avec --no-cache).
D'autre part, si un layer remarque un changement, et donc une obsolescence de cache, les layers d'après vont aussi être rebuild.

Schema de fonctionnement des layers

Pour build une image Docker, il faut un fichier dockerfile et build:

docker build .

Le . permet de donner à Docker l'environnement de build (AKA le dossier courant).

On peut aussi build une image avec un Dockerfile n'ayant pas un nom conventionnel: -f ./Dockerfile_with_a_special_name.

Il est recommandé de nommer les versions de nos images docker. Pour cela, on peut lui donner un nom (cf les tags vu avant) \o/:

docker build -t my_awesome_image:version .

Dockerfile

Explorons un peu les différentes instructions qui sont utiles. Vous pourrez trouver la spec online.

FROM

Pour faire un Dockerfile valide, cette instruction est obligatoire. Une image docker a toujours besoin de partir d'un layer existant.
Elle permet de connaitre l'image de base sur laquelle se situe la future image. (Vous pouvez trouver une liste des images de base disponible sur le repository docker).
Et ça ressemble à ça:

FROM <image>[:<tag>] [AS <name>]

Essayons:

  • On va mettre dans un fichier Dockerfile ceci:
    ​​​​​FROM ubuntu:19.04
    
  • Puis build l'image:
    ​​​​docker build .
    

On obtient ça:

> docker build .
Sending build context to Docker daemon  2.048kB
Step 1/1 : FROM ubuntu:19.04
 ---> 9f3d7c446553
Successfully built 9f3d7c446553

On peut observer que docker va "envoyer" le Dockerfile au daemon docker pour qu'il puisse build.
Ce daemon va exécuter la seule étape, retourner le digest du layer et finir de build l'image.

RUN

C'est bien joli d'utiliser une image de base, mais comment spécifiez des instructions pendant le build me direz vous, c'est avec l'instruction RUN : Elle permet d'exécuter une commande shell lors du build.

On peut utiliser la version "simple":

RUN <command>

Cela va exécuter la commande avec le shell par défaut de l'OS : /bin/sh -c pour Linux ou cmd /S /C sur windows.

On peut aussi utiliser la version "exec":

RUN ["executable", "param1", "param2", ...]

Tips: On peut utiliser des backslash \ pour faire des RUN sur plusieurs lignes.

Essayons:

  • On va repartir du fichier Dockerfile de la dernière fois:
    ​​​​​FROM ubuntu:19.04
    
  • Et y ajouter une commande:
    ​​​​​FROM ubuntu:19.04
    ​​​​​RUN cat /etc/os-release
    
  • Puis build l'image:
    ​​​​docker build .
    

On obtient ça:

> docker build .
Sending build context to Docker daemon  2.048kB
Step 1/2 : FROM ubuntu:19.04
 ---> 9f3d7c446553
Step 2/2 : RUN cat /etc/os-release
 ---> Running in 41ff7a13ca27
NAME="Ubuntu"
VERSION="19.04 (Disco Dingo)"
ID=ubuntu
ID_LIKE=debian
PRETTY_NAME="Ubuntu 19.04"
VERSION_ID="19.04"
HOME_URL="https://www.ubuntu.com/"
SUPPORT_URL="https://help.ubuntu.com/"
BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/"
PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy"
VERSION_CODENAME=disco
UBUNTU_CODENAME=disco
Removing intermediate container 41ff7a13ca27
 ---> 92b8b7f95f77
Successfully built 92b8b7f95f77

On peut observer le caching des instructions en faisant un rebuild:

> docker build .
Sending build context to Docker daemon  2.048kB
Step 1/2 : FROM ubuntu:19.04
 ---> 9f3d7c446553
Step 2/2 : RUN cat /etc/os-release
 ---> Using cache
 ---> 92b8b7f95f77
Successfully built 92b8b7f95f77
COPY, ADD

Maintenant qu'on peut exécuter des commandes shell dans le container, autant ne pas rester dans un monde fonctionnel pur et faisons des effets de bords sur des fichiers.

# ADD
ADD <src>... <dest>
ADD ["<src>",... "<dest>"]

# COPY
COPY <src>... <dest>
COPY ["<src>",... "<dest>"]

La différence entre les deux est que ADD permet l'ajout de fichier depuis des sources différentes : COPY ajoute des fichiers ou dossiers alors que ADD peut aussi manipuler des fichiers distants (via URLs).

Essayons de faire un hello world en python:

  • On va créer un ficher python basique:
    ​​​​​echo "print('hello world')" > app.py
    
  • On va partir d'une image python pour le Dockerfile:
    ​​​​​FROM python:3.8
    ​​​​​COPY app.py .
    ​​​​​RUN python app.py
    
  • Puis build l'image:
    ​​​​docker build .
    

On obtient ça:

> docker build .
Sending build context to Docker daemon   5.12kB
Step 1/3 : FROM python:3.8
 ---> 28a4c88cdbbf
Step 2/3 : COPY app.py .
 ---> f6db079cdc6c
Step 3/3 : RUN python app.py
 ---> Running in 972191f3bec2
Hello, World!
Removing intermediate container 972191f3bec2
 ---> be8feedcb09d
Successfully built be8feedcb09d
ENV vs ARG

Pour de la configuration plus flexible, Docker est friand des variables d'environnement.
Ce sont des variables dont on peut avoir la valeur au runtime.

Par exemple, on pourrait stoker en variable d'environnement le login de la base de données ou le numéro de port à bind.

Dans le Docker file, ces variables sont indiquées avec la commande ENV:

ENV DB_PASS=super_secure_password
ENV PORT=8080

Ces variables vont aussi pouvoir être changées à l'utilisation du container.

Tips: Pour des variables uniquement utiles au build de l'image, on peut utiliser ARG

Essayons de faire un hello world en rust :

  • On va créer un ficher rust basique app.rs:
    ​​​​​use std::env;
    
    ​​​​​fn main() {
    ​​​​​    match env::var("LANG") {
    ​​​​​        Ok(lang) => {
    ​​​​​            if lang.eq(&String::from("EN")) {
    ​​​​​                println!("Hello World");
    ​​​​​            } else {
    ​​​​​                println!("Bonjour Monde");
    ​​​​​            }
    ​​​​​        }
    ​​​​​        Err(e) => println!("Couldn't read LANG ({})", e),
    ​​​​​    };
    ​​​​​}
    
  • On va partir d'une image rust pour le Dockerfile:
    ​​​​​FROM rust:1.31
    ​​​​​COPY app.rs .
    ​​​​​RUN rustc app.rs -o app
    ​​​​​ENV LANG=EN
    ​​​​​RUN ./app
    
  • Puis build l'image:
    ​​​​docker build .
    

On obtient ça:

> docker build .
Sending build context to Docker daemon   16.9kB
Step 1/5 : FROM rust:1.31
 ---> 6f61eb35ad91
Step 2/5 : COPY app.rs .
 ---> d5b1570b2c63
Step 3/5 : RUN rustc app.rs -o app
 ---> Running in e6354d1ff410
Removing intermediate container e6354d1ff410
 ---> 24d81400a54e
Step 4/5 : ENV LANG=EN
 ---> Running in 401cf76f6475
Removing intermediate container 401cf76f6475
 ---> ac92e5f19357
Step 5/5 : RUN ./app
 ---> Running in 5d3c41a3a85e
Hello World
Removing intermediate container 5d3c41a3a85e
 ---> d8349d6d185c
Successfully built d8349d6d185c

Et si on change la valeur de la variable, on obtient ça:

> docker build .
Sending build context to Docker daemon   16.9kB
Step 1/5 : FROM rust:1.31
 ---> 6f61eb35ad91
Step 2/5 : COPY app.rs .
 ---> Using cache
 ---> d5b1570b2c63
Step 3/5 : RUN rustc app.rs -o app
 ---> Using cache
 ---> 24d81400a54e
Step 4/5 : ENV LANG=FR
 ---> Running in 59fc11b76b80
Removing intermediate container 59fc11b76b80
 ---> 5535e8d40a4c
Step 5/5 : RUN ./app
 ---> Running in 16d58e234947
Bonjour Monde
Removing intermediate container 16d58e234947
 ---> 44f72d0290f1
Successfully built 44f72d0290f1
CMD vs ENTRYPOINT

Maintenant qu'on a pu compiler, on va lancer le binaire au démarrage du container.
On verra comment lancer le container plus tard.

Pour ça, on met:

ENTRYPOINT ["executable", "param1", "param2"]
CMD <command>

ENTRYPOINT permet de définir un point d'entrée pour le container.

Dans le cas où un ENTRYPOINT est défini, il est possible de définir un CMD afin de créer des options supplémentaires. Dans la pratique, ENTRYPOINT et CMD ont un rôle très similaire. Cependant, ENTRYPOINT est statique alors que CMD est dynamique. Il est alors possible de créer un ENTRYPOINT couplé à un CMD afin d'avoir une part d'arguments statiques et dynamiques. Attention, cette pratique n'est pas simple à gérer et demande de la pratique.

Par exemple, pour le Reverse Proxy Traefik, son Dockerfile utilise ENTRYPOINT pour démarrer le binaire et CMD pour lui donner des paramètres.

Essayons de faire un hello world en rust :

  • On va partir d'une image ubuntu pour le Dockerfile:
    ​​​​​FROM ubuntu:19.04
    ​​​​​ENTRYPOINT ["bash"]
    ​​​​​CMD ["--posix"]
    
  • Puis build l'image:
    ​​​​docker build .
    

On obtient ça:

> docker build .
Sending build context to Docker daemon  3.072kB
Step 1/3 : FROM ubuntu:19.04
 ---> 9f3d7c446553
Step 2/3 : ENTRYPOINT ["bash"]
 ---> Running in d7c726e2c46d
Removing intermediate container d7c726e2c46d
 ---> ef0aacedc3fb
Step 3/3 : CMD ["--posix"]
 ---> Running in 41fcaa66d49b
Removing intermediate container 41fcaa66d49b
 ---> 439853250ab9
Successfully built 439853250ab9

A noter que lors du run du container, l'instruction CMD va pouvoir pouvoir être remplacée. Alors que l'instruction ENTRYPOINT non.

WORKDIR

On a réussi à lancer des commandes, mais on a jamais spécifié d'où on partait. On va maintenant utiliser WORKDIR pour le faire.

# WORKDIR
WORKDIR /path/to/workdir

Un exemple de Dockerfile :

FROM ubuntu:19.04 WORKDIR a WORKDIR b WORKDIR c RUN pwd WORKDIR /d/e/f RUN pwd

Et sa sortie :

> docker build .
docker build .
Sending build context to Docker daemon  3.072kB
Step 1/7 : FROM ubuntu:19.04
 ---> 9f3d7c446553
Step 2/7 : WORKDIR a
 ---> Running in 862c9c25b001
Removing intermediate container 862c9c25b001
 ---> b5182f87efaf
Step 3/7 : WORKDIR b
 ---> Running in a025044916fb
Removing intermediate container a025044916fb
 ---> 00639a0c768a
Step 4/7 : WORKDIR c
 ---> Running in f7636684c808
Removing intermediate container f7636684c808
 ---> c443fe035762
Step 5/7 : RUN pwd
 ---> Running in 666de2796715
/a/b/c
Removing intermediate container 666de2796715
 ---> 933bc24f7abd
Step 6/7 : WORKDIR /d/e/f
 ---> Running in 6a73fa4b4b1a
Removing intermediate container 6a73fa4b4b1a
 ---> 81d89d1f7712
Step 7/7 : RUN pwd
 ---> Running in df4d54248466
/d/e/f
Removing intermediate container df4d54248466
 ---> cd3537b0b0a7
Successfully built cd3537b0b0a7
EXPOSE

Les étapes précédentes vous ont permis de créer un Dockerfile et de faire un docker pouvant faire tourner une application. Il faut maintenant laisser les utilisateurs accéder à votre application. Pour cela, on utilise la commande EXPOSE.

En Dockerfile cela se traduit simplement :

# EXPOSE
EXPOSE <port> [<port>/<protocol>...]

#En pratique
EXPOSE 80/tcp
FROM python:3.8 EXPOSE 8000 CMD python -m http.server 8000

Avec ce Dockerfile, le docker aura un port ouvert qu'il faudra ensuite bind à un de vos ports. Cela sera vu et expliqué plus tard.

On obtient ça:

> docker build .
Sending build context to Docker daemon  3.072kB
Step 1/3 : FROM python:3.8
 ---> 28a4c88cdbbf
Step 2/3 : EXPOSE 8000
 ---> Running in 74ed5b622239
Removing intermediate container 74ed5b622239
 ---> 7eeb65ba6434
Step 3/3 : CMD python -m http.server 8000
 ---> Running in c3d9cee464b8
Removing intermediate container c3d9cee464b8
 ---> c82182474b7f
Successfully built c82182474b7f
Aller plus loin
  • HEALTHCHECK
  • USER
  • .dockerignore
  • multi layer images: On peut faire des images plus légère en utilisant des images temporaires pour compiler les binaires:
    ​​​​​# image pour compiler le go
    ​​​​​FROM golang:1.13.4 AS builder
    ​​​​​# ...
    ​​​​​RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app .
    
    ​​​​​# image plus légére pour run le go
    ​​​​​FROM scratch
    ​​​​​COPY --from=builder app .
    ​​​​​# ....
    

CLI

Après la création de Dockerfile, utilisons les vraiment !

build
docker build [OPTIONS] PATH | URL | -

La commande build de Docker permet de créer une image qui va ensuite pouvoir être lancée et transformée en conteneur.

Quelques options utiles :

  • -t <name> permet de donner un nom à l'image créée (tag)
  • -f <path> permet de spécifier un Dockerfile
  • --no-cache permet de build sans utiliser le cache de docker

Pour en savoir plus sur les options de build : Spec

Essayons avec l'image que nous avons fait

tag
docker tag SOURCE_IMAGE[:TAG] TARGET_IMAGE[:TAG]

Docker tag permet de donner un nom à une image afin de la retrouver plus facilement.
Une image qui a été build ou une image modifiée peut alors être nommée facilement.
On se sert souvent de cette command en entreprise pour s'y retrouver parmi les différentes images créées.

Les Tags sont composés de 4 parties:

  • registery : correspond au serveur d'images à utiliser(comme docker.io, goharbor.io)
  • author : C'est l'auteur de l'image. Si l'auteur est _ (c'est-à-dire Docker), il peut être omis. Par exemple, l'image nginx qui peut être utilisée dans auteur.
  • name : C'est le nom de l'image : wordpress par exemple.
  • tag : (oui, le tag du tag) permet de connaitre la version de l'image. Le tag par défaut est latest. Pour les images qui sont associées à :
    • des langages, comme l'image haskell, le tag correspond à la version du langage : haskell:8.8.4
    • des services, comme l'image gitlab-runner, le tag correspond à la version du service et à la version des layers sous-jacents : gitlab/gitlab-runner:alpine-bleeding, gitlab/gitlab-runner:alpine-v13.5.0-rc2, gitlab/gitlab-runner:ubuntu-v13.4.0-rc1

Une liste non exhaustive de tags valides:

  • docker.io/michel/awesomeapp:latest
  • michel/awesomeapp
  • mysql
# Depuis un ID de votre image locale
> docker tag 0e5574283393 image/httpd:version1.0

# Depuis une autre image locale nommée
> docker tag my-image epita/my-image:version1.0

# Depuis une image et un tag
> docker tag my-image:beta0.1 epita/my-image:version1.0.test
push
docker push [OPTIONS] NAME[:TAG]

Cette commande permet de mettre en ligne dans le répertoire d'image de votre choix.
Cela peut être le DockerHub ou une registry privée.

# On push une rhel (image red hat) dans une registry
> docker push registry-host:5000/myadmin/rhel-httpd

Tip: Les registry utilisent des comptes pour repérer les images, il faut donc faire un docker login pour se connecter et pouvoir push l'image voulue:

docker login [OPTIONS] [SERVER]
run
docker run [OPTIONS] IMAGE[:TAG|@DIGEST] [COMMAND] [ARG...]

Docker run permet de lancer des conteneurs à partir d'une image.

# Exemple simple docker run
> docker run -d -p 80:80 my_image service nginx start

Expliquons maintenant quelques options utiles :

  • -p permet de bind un port du conteneur à un des ports de votre PC
  • -d permet de lancer le conteneur en mode détaché (en gros vous le lancez en background)
  • -it afin d'avoir des process interactifs (et du coup lancer des commandes en plus dans votre docker)
  • -e définit des variables d'environnement dans le container
  • -v mount un volume dans le container: permet d'avoir de la persistance de données
  • --name donne un nom au container
  • --rm supprime le container au moment où il ne fonctionne plus

Un autre exemple d'utilisation du run pour avoir un container Ubuntu pour faire des test:

docker run -it --rm ubuntu bash

Si on ferme le tty de ce container (ctrl-d ou exit), il sera supprimé ; vous pouvez essayer de le casser ;)

exec
docker exec [OPTIONS] CONTAINER COMMAND [ARG...]

Docker exec permet de lancer des commandes dans des conteneurs existant.

Avec Docker exec, il est possible de rentrer dans le conteneur afin de faire du debug (car comme tout épitéen normalement constitué, vous n'êtes pas des dieux du code).

Quelques exemples de Docker exec :

# Afficher des informations internes au docker
> docker exec -ti my_container sh -c "echo a && echo b"

# Entrer dans le conteneur
> docker exec -ti ubuntu bash
ps
docker ps [OPTIONS]

Docker ps permet de lister les conteneurs déployés

# Les conteneurs qui tournent
> docker ps
CONTAINER ID IMAGE         COMMAND     CREATED      STATUS     PORTS  NAMES
2db9bb3bc2ea ubuntu:latest "/bin/bash" 8 months ago Up 9 hours        Ubuntrash

# tous les conteneurs (même ceux à l'arret)
> docker ps -a
CONTAINER ID IMAGE         COMMAND     CREATED        STATUS                      PORTS NAMES
45866acdddd6 ubuntu        "bash"      13 minutes ago Exited (0) 12 minutes ago         nifty_aryabhata
2db9bb3bc2ea ubuntu:latest "/bin/bash" 8 months ago   Up 9 hours                        Ubuntrash

Plus d'options disponibles ici : link

Tip: Pour arrêter tous les container qui sont en train de tourner, on peut faire : docker stop $(docker ps -q)

stop
docker stop [OPTIONS] CONTAINER [CONTAINER...]

Cette commande va envoyer un SIGTERM au process du container puis un SIGKILL après une période de grâce pour lui laisser le temps de s'éteindre.

network
docker network COMMAND
docker network create [OPTIONS] NETWORK
docker network connect [OPTIONS] NETWORK CONTAINER
docker network disconnect [OPTIONS] NETWORK CONTAINER
docker network inspect [OPTIONS] NETWORK [NETWORK...]
docker network ls [OPTIONS]
docker network rm NETWORK [NETWORK...]

Vous êtes des malades si vous utilisez ces commandes à la main !

Mais les commandes sont suffisamment simples pour comprendre ce qu'elles font:

  • create permet de créer un network
  • connect permet de connecter un container à un network
  • disconnect permet de déconnecter un container à un network
  • inspect permet d'inspecter un network pour avoir des informations dessus
  • ls permet de lister les networks
  • rm permet de supprimer un/des network(s).

docker-compose

Woaw, vous avez atteint la moité de la partie cours du TP \o/

Vous savez maintenant utiliser docker (théoriquement) avec plein d'options.

Pourquoi faire du docker-compose ?

Mais utiliser docker en CLI c'est gentil 2 minutes (qui compile son C sans un Makefile ?).

docker-compose est une sur-couche de docker qui permet de mettre dans un fichier tous les arguments qu'on aurait donné à docker. Mais aussi de gérer de multiples conteneurs en simultané plus facilement.

Le but est donc de transformer un :

docker run \ --name=transmission \ -e PUID=1000 \ -e PGID=1000 \ -e TZ=Europe/London \ -e TRANSMISSION_WEB_HOME=/combustion-release/ `#optional` \ -e USER=username `#optional` \ -e PASS=password `#optional` \ -p 9091:9091 \ -p 51413:51413 \ -p 51413:51413/udp \ -v <path to data>:/config \ -v <path to downloads>:/downloads \ -v <path to watch folder>:/watch \ --restart unless-stopped \ linuxserver/transmission

en :

docker-compose up

Ca vous parait magique ? :mage: Alors apprenons tous ensemble comment ça marche !

Comment faire du docker-compose ?

Encore une fois, la spec est disponible en ligne.

docker-compose.yml

Comme pour utiliser docker, docker-compose utilise des fichiers docker-compose.yml avec un formal yaml (wiki).

Il sont composés de plusieurs parties qui décrivent des requirements à match:

  • Des services (expliqué plus loin)
  • Des networks pour lier de containers
  • Des volumes pour garder des fichiers persistants
  • Des secrets (peut utilisés) pour avoir des données chiffrée
  • Soon des imports
version

Pour chaque version de docker-compose, la version que le programme va utiliser va changer.

En effet, la version '1' n'est plus utilisée, la '2' continue d'être utilisée et la '3' est la plus complète.

Pour spécifier la version, il suffit de faire:

version: '3.6'

Si notre fichier docker-compose.yml ne contient que le numéro de version, il ne se passe rien lorsqu'on l'utilise:

  • On met le numéro de version dans un fichier docker-compose.yml:
    ​​​​​version: '3.6'
    
  • On exécute la commande pour savoir si tout s'est bien lancé:
    ​​​​​> docker-compose up
    ​​​​​Attaching to
    ​​​​​>
    

docker-compose up permet de lancer les services décrits dans le fichier docker-compose.yml, mais on en parlera plus tard.

networks

Les networks permettent de mettre des container sur un même réseau virtuel (par défaut, les networks sont en mode bridge).

La spécification est assez simple, il faut le spécifier:

networks:
  network-name:

Les valeurs par défaut suffisent amplement pour toute utilisation normale de docker-compose. (La doc est disponible pour les gens hardcore qui veulent allez plus loin)

Pour essayer:

  • On peut partir du fichier docker-compose.yml du dernier exemple:
    ​​​​version: '3.6'
    
  • Et lui ajouter un network:
    ​​​​version: '3.6'
    
    ​​​​networks:
    ​​​​    network-name:
    
  • On execute:
    ​​​​> docker-compose up
    ​​​​WARNING: Some networks were defined but are not used by any service: network-name
    ​​​​Attaching to
    ​​​​>
    
  • On réfléchit:
    Vu que nous n'avons pas encore mis de services (oui, ça arrive, ça arrive), docker-compose ne peut pas faire un lien entre les containers et les networks, c'est pour ça qu'il indique une erreur.
volumes

Pour conserver des fichiers entre deux runs de containers, on peut créer des volumes.

Il y a deux moyens de créer des volumes:

  • En utilisant des dossiers et des fichier locaux (a faire directement dans le service)
  • En utilisant un file system virtuel

Pour cela, on peut faire:

volumes:
  volume-name:
    external: false

Sachant que l'on peut utiliser des file system distants, mais c'est pas fou.

services

Voilà enfin la partie intéressante du fichier docker-compose, et là où on va passer le plus de temps.

Le service va donc transformer une image docker en container. Il va décrire à docker-compose (et donc docker) comment run le container.

Un fichier docker-compose.yml va donc se composer de plusieurs parties:

version: '3.6'

networks:
  network-toto:

volumes:
  volume-tata:

services:
  mon-service_1:
    ...
  mon-service_2:
    ...
image

C'est là qu'on spécifie le nom de l'image à utiliser pour le service.
Ce nom va servir à la fois si on veut pull l'image et si on veut la build localement.

Cet argument à la même forme que lorsqu'on a fait des FROM dans le Dockerfile: [repo/][user/]<nom>[:tag].

On peut donc faire:

  • Partir du docker-compose.yml précédent:

    ​​​​version: '3.6'
    
    ​​​​networks:
    ​​​​  network-name:
    
  • Et lui ajouter un service avec un nom d'image:

    ​​​​version: '3.6'
    
    ​​​​networks:
    ​​​​  network-name:
    
    ​​​​services:
    ​​​​  swagger:
    ​​​​    image: swaggerapi/swagger-ui
    
  • Et on exécute:

    ​​​​> docker-compose up
    ​​​​WARNING: Some networks were defined but are not used by any service: network-name
    ​​​​Creating network "demo_default" with the default driver
    ​​​​Pulling swagg (swaggerapi/swagger-ui:)...
    ​​​​latest: Pulling from swaggerapi/swagger-ui
    ​​​​188c0c94c7c5: Pull complete
    ​​​​61c2c0635c35: Pull complete
    ​​​​378d0a9d4d5f: Pull complete
    ​​​​2fe865f77305: Pull complete
    ​​​​b92535839843: Pull complete
    ​​​​5a845b721bd5: Pull complete
    ​​​​11a7ab608318: Pull complete
    ​​​​2940893bd159: Pull complete
    ​​​​6e4cbe1b9035: Pull complete
    ​​​​9f938b7a4eb0: Pull complete
    ​​​​d58a47b6172e: Pull complete
    ​​​​Digest: sha256:17c0001f808d76c6833e6f33b965ceb1aa4955a277eb826e0eb3369eb54aae91
    ​​​​Status: Downloaded newer image for swaggerapi/swagger-ui:latest
    ​​​​Creating demo_swagg_1 ... done
    ​​​​Attaching to demo_swagg_1
    ​​​​^CGracefully stopping... (press Ctrl+C again to force)
    ​​​​Stopping demo_swagg_1 ... done
    

    En analysant, on peut remarquer que:

    • Le network n'est pas utilisé
    • L'image n'était pas présente sur ma machine, docker-compose va pull l'image directement.
    • Le container est attaché au tty actuel et si on sort du container (Ctrl-d ou exit) le container est stoppé.
build

La directive build va permettre à docker-compose de décrire à docker comment build son image, tous les champs de docker build.

build:
  context: ./dir
  dockerfile: Dockerfile-alternate
  args:
    buildno: 1

On peut donc spécifier:

  • Le dossier contexte dans lequel faire le build
  • Le nom du ficher Dockerfile
  • Les ARGS a donner au fichier

Essayons:

  • Si on reprend le dockerfile pour le hello world en rust (ne pas oublier le fichier app.rs):
    ​​​​ FROM rust:1.31
    ​​​​ COPY app.rs .
    ​​​​ RUN rustc app.rs -o app
    ​​​​ ARG LANG
    ​​​​ RUN ./app
    
  • Et on peut faire un fichier docker-compose.yml pour build:
    ​​​​version: '3.6'
    
    ​​​​services:
    ​​​​​ my_rusty_docker:
    ​​​​​   image: tommoulard/my_rusty-container
    ​​​​​   build:
    ​​​​​     context: .
    ​​​​​     dockerfile: Dockerfile
    ​​​​​     args:
    ​​​​​       LANG: EN
    
  • Et il n'y a plus qu'à lancer:
    ​​​​> docker-compose build
    ​​​​Building my_rusty_docker
    ​​​​Step 1/5 : FROM rust:1.31
    ​​​​1.31: Pulling from library/rust
    ​​​​cd8eada9c7bb: Pull complete
    ​​​​c2677faec825: Pull complete
    ​​​​fcce419a96b1: Pull complete
    ​​​​045b51e26e75: Pull complete
    ​​​​3b969ad6f147: Pull complete
    ​​​​2074c6bfed7d: Pull complete
    ​​​​Digest: sha256:e2c4e3751290e30c3f130ef3513c7999aee87b5e7ac91e2fc9f3addcdf1f1387
    ​​​​Status: Downloaded newer image for rust:1.31
    ​​​​ ---> 6f61eb35ad91
    ​​​​Step 2/5 : COPY app.rs .
    ​​​​ ---> 938d9a7ff71d
    ​​​​Step 3/5 : RUN rustc app.rs -o app
    ​​​​ ---> Running in f25b678abb66
    ​​​​Removing intermediate container f25b678abb66
    ​​​​ ---> 9e6749471396
    ​​​​Step 4/5 : ARG LANG=FR
    ​​​​ ---> Running in 1d1cb6dd7206
    ​​​​Removing intermediate container 1d1cb6dd7206
    ​​​​ ---> 3434f48a83da
    ​​​​Step 5/5 : RUN ./app
    ​​​​ ---> Running in 1a17201ae1d1
    ​​​​Hello World
    ​​​​Removing intermediate container 1a17201ae1d1
    ​​​​ ---> 61a07c21cc05
    
    ​​​​Successfully built 61a07c21cc05
    ​​​​Successfully tagged tommoulard/my_rusty-container:latest
    
restart

Cette option permet de définir la politique de redémarrage du container en cas d'arrêt (segfault, erreur, ).

Elle est définit comme:

restart: "no" # do not restart (default value)
restart: always # alway restart
restart: on-failure # restart only when there's an error (exit code != 0)
restart: unless-stopped # always restart the container except when the container is stopped (manually or otherwise)

Tips: utiliser restart: unless-stopped est souvent une bonne idée.

Par exemple:

  • On prend un fichier docker-compose.yml vierge, et on y met:
    ​​​​version: '3.6'
    
    ​​​​services:
    ​​​​  ubuntrash:
    ​​​​    image: ubuntu:20.04
    ​​​​    command: ["/bin/sh", "-c", "echo 'failure'", "&&", "exit 1"]
    ​​​​    restart: always
    
  • Si on build l'image, on obtient:
    ​​​​>  docker-compose up
    ​​​​Creating network "demo_default" with the default driver
    ​​​​Pulling ubuntrash (ubuntu:20.04)...
    ​​​​20.04: Pulling from library/ubuntu
    ​​​​6a5697faee43: Pull complete
    ​​​​ba13d3bc422b: Pull complete
    ​​​​a254829d9e55: Pull complete
    ​​​​Digest: sha256:fff16eea1a8ae92867721d90c59a75652ea66d29c05294e6e2f898704bdb8cf1
    ​​​​Status: Downloaded newer image for ubuntu:20.04
    ​​​​Creating demo_ubuntrash_1 ... done
    ​​​​Attaching to demo_ubuntrash_1
    ​​​​ubuntrash_1  | failure
    ​​​​demo_ubuntrash_1 exited with code 0
    ​​​​demo_ubuntrash_1 exited with code 0
    
  • Si on inspecte le container, on peut voir qu'il restart sans cesse:
    ​​​​> docker-compose ps
    ​​​​         Name                   Command             State      Ports
    ​​​​--------------------------------------------------------------------
    ​​​​demo_ubuntrash_1   echo failure   Restarting
    ​​​​> docker-compose logs ubuntrash
    ​​​​Attaching to demo_ubuntrash_1
    ​​​​ubuntrash_1  | failure
    ​​​​ubuntrash_1  | failure
    ​​​​ubuntrash_1  | failure
    ​​​​ubuntrash_1  | failure
    ​​​​ubuntrash_1  | failure
    ​​​​ubuntrash_1  | failure
    ​​​​ubuntrash_1  | failure
    ​​​​ubuntrash_1  | failure
    ​​​​ubuntrash_1  | failure
    ​​​​ubuntrash_1  | failure
    ​​​​ubuntrash_1  | failure
    

Tips: Si vous voulez arrêter le redémarrage en boucle des services, vous pouvez faire docker-compose down

network

Pour faire un lien réseau entre plusieurs container, on peut utiliser networks.

Si on veut connecter une base de données et un backend d'application, on peut les mettre sur le même network et ainsi pouvoir scale la DB autant que possible:

graph LR
    subgraph application-network
        r[redis]
        lb[Load  Balancer]
        lb --> b
        lb --> r
        subgraph db-network
            b[backend]
            db1[(Database)]
            db2[(Database)]
            db3[(Database)]
            b --> db1
            b --> db2
            b --> db3
        end
    end

Et comme docker c'est super cool, depuis l'intérieur d'un container, pour accéder aux autres containers, leur hostname corresponds à leurs noms (docker is love).

Si on fait docker-compose up avec ce fichier:

version: '3.6'

networks:
  demo-net:

services:
  python:
    image: python:3.9-alpine
    command: ["/bin/ping", "-c", "5", "my-nginx"]
    networks:
      - 'demo-net'

  my-nginx:
    image: nginx:alpine
    networks:
      - 'demo-net'

On obtient:

> docker-compose up
Recreating demo_python_1 ... done
Starting demo_my-nginx_1 ... done
Attaching to demo_my-nginx_1, demo_python_1
python_1    | PING my-nginx (172.23.0.2): 56 data bytes
python_1    | 64 bytes from 172.23.0.2: seq=0 ttl=64 time=0.095 ms
my-nginx_1  | /docker-entrypoint.sh: /docker-entrypoint.d/ is not empty, will attempt to perform configuration
my-nginx_1  | /docker-entrypoint.sh: Looking for shell scripts in /docker-entrypoint.d/
my-nginx_1  | /docker-entrypoint.sh: Launching /docker-entrypoint.d/10-listen-on-ipv6-by-default.sh
my-nginx_1  | 10-listen-on-ipv6-by-default.sh: error: IPv6 listen already enabled
my-nginx_1  | /docker-entrypoint.sh: Launching /docker-entrypoint.d/20-envsubst-on-templates.sh
my-nginx_1  | /docker-entrypoint.sh: Configuration complete; ready for start up
python_1    | 64 bytes from 172.23.0.2: seq=1 ttl=64 time=0.151 ms
python_1    | 64 bytes from 172.23.0.2: seq=2 ttl=64 time=0.149 ms
python_1    | 64 bytes from 172.23.0.2: seq=3 ttl=64 time=0.141 ms
python_1    | 64 bytes from 172.23.0.2: seq=4 ttl=64 time=0.151 ms
python_1    |
python_1    | --- my-nginx ping statistics ---
python_1    | 5 packets transmitted, 5 packets received, 0% packet loss
python_1    | round-trip min/avg/max = 0.095/0.137/0.151 ms
demo_python_1 exited with code 0
^CGracefully stopping... (press Ctrl+C again to force)
Stopping demo_my-nginx_1 ... done

Tips, pour faire des connections en "One to many", on peut utiliser links

environment

Une best practice de docker-compose est de donner la configuration des process qui tournent dans un container par des variables d'environnement.

On peut utiliser les variables d'environnement de deux manières différentes:

environment:
    MYSQL_ROOT_PASSWORD: example
    # or
    - 'MYSQL_ROOT_PASSWORD=example'

Par exemple, le hub de mysql, nous propose de faire:

version: '3.1'

services:

  db:
    image: mysql
    command: --default-authentication-plugin=mysql_native_password
    restart: always
    environment:
      MYSQL_ROOT_PASSWORD: example

  adminer:
    image: adminer
    restart: always
    ports:
      - 8080:8080

Et après avoir fait docker-compose up et être allé sur localhost:8080. On peut voir qu'adminer nous permet de nous connecter avec les credentials root:example sur mysql et donc que le mdp par défaut de la base de donnée a été changé.

volumes

Comme nous avons vu précédemment, on aimerait bien garder des données sur le disk même si on supprime le container, et pour ça on utilise des volumes.

Les volumes virtuels sont bien mais il y a toujours des frictions pour les utiliser.

On a donc deux manières d'utiliser des volumes:

volumes:
  - 'data:/data' # With docker virtual fs
  - '/srv/data:/data' # Using a file or a folder using the absolute path
  - './data:/data' # docker-compose allows to use a relative path too

On les reconnait grâce au premier caractère:

  • si on a un / ou un ., c'est un fs local
  • sinon, c'est un file system virtuel

Pour reprendre l'exemple de mysql, si on a envi d'utiliser des volumes, on peut faire:

version: '3.1'

services:

  db:
    image: mysql
    command: --default-authentication-plugin=mysql_native_password
    restart: always
    environment:
      MYSQL_ROOT_PASSWORD: example
    volumes:
      - './mysql/data:/var/lib/mysql'

  adminer:
    image: adminer
    restart: always
    ports:
      - 8080:8080

Tips, si on utilise un chemin du fs courant, et que le dossier n'existe pas, docker va en créer un par défaut.

ports

Vous l'avez sûrement deviné avec l'exemple précédent, l'argument port permet d'exporter des ports.

Ils ont deux modes d'expressions:

ports:
  # shorts
  - "3000"
  - "3000-3005"
  - "8000:8000"
  - "9090-9091:8080-8081"
  - "49100:22"
  - "127.0.0.1:8001:8001"
  - "127.0.0.1:5000-5010:5000-5010"
  - "6060:6060/udp"
  - "12400-12500:1240"

  # long
  - target: 80
    published: 8080
    protocol: tcp
    mode: host
depends_on

depends_on permet de dire à docker-compose d'attendre que le container démarre avant de lancer le suivant. On pourrait avoir envie d'attendre que la base de données soit initialisée avant de lancer le container qui va l'utiliser.

Attention, depends_on n'attend pas l'initialisation complète du container, il attend qu'il soit lancé. Si vous voulez attendre l'initialisation, je vous conseille d'aller jeter un coup d'œil ici.

Par exemple, pour déployer un site en django, on pourrait avoir le fichier docker-compose.yml:

version: '3.6'
services:
  db:
    image: postgres
  django:
    build: .
    command: python manage.py runserver 0.0.0.0:8000
    volumes:
      - '.:/code'
    ports:
      - '8000:8000'
    depends_on:
      - 'db'
Fichiers .env

docker-compose va lire automatiquement les fichiers .env que vous aurez dans vos dossier pour avoir des configurations plus explicites.

Aller plus loin
  • secrets
  • labels
  • user

CLI

  • ps
  • up / down
  • build
Aller plus loin
  • scale
  • Ne pas avoir tous ses services dans un seul fichier docker-compose.yml

Projet

Pour s'amuser un peu après autant de cours, on va faire une petite mise en pratique.

Le but va être de compiler un projet en Go et de le déployer pour l'utiliser.

Pour cela, vous trouverez sur le repository github tous les fichiers nécessaires pour faire le devoir.

Fork

Le rendu du devoir se fera sur github directement. Pour cela, il va falloir fork le projet et réaliser le maximum d'étapes. Nous regarderons les forks du projets à la fin du temps impartit (cf le haut du TP). N'oubliez pas de push ce que vous faites !

Toutes les optimisations seront regardées et prises en compte, donc n'hésitez pas à en faire !

Docker

La première étape va être de créer une image avec le binaire go.

Points bonus: Celui qui fait l'image la plus petite gagne !

Compiler du go

Pour compiler un binaire go, il suffit de faire:

go get -d -v \ # to get dependencies
    github.com/lib/pq \
    github.com/julienschmidt/httprouter
go build -o a.out # to compile to a binary a.out

Tips, On peut compiler du go statiquement (avec toutes les librairies) avec quelques variations:

CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o a.out

Ne pas oublier d'exposer un port (je conseille d'utiliser le port 8000).

docker-compose

Après avoir créé l'image Docker, il faut l'utiliser.

On va donc devoir lancer le container, exposer les ports et configurer l'app.

L'application Go utilise 4 arguments (via les variables d'environnement):

  • IP: L'IP à utiliser pour l'application
  • PORT: Le port que va bind l'application
  • POSTGRES_URL: L'adresse de la base de données
  • POSTGRES_PASSWORD: Le mot de passe de la base de données

Postgresql

Il faudra aussi, pour que l'application fonctionne, une base de données : postgres.

Il faudra donc que l'application go soit capable d'accéder à la base de données.

Et il faudra faire en sorte que les données (situées dans /var/lib/postgresql/data) soient préservées.

Tips, il n'y a pas besoin de toucher aux user de Postgres, il faut set le password.

Test

Pour tester, il n'y a rien de mieux que de faire docker build puis docker-compose up, n'hésitez pas à le faire régulièrement.

Il ne faut surtout pas hésiter à poser des questions aux assistants, quitte à se faire debugger. Ils sont là pour ça. Ne les laissez pas s'ennuyer.

Pour aller voir l'interface web, il faut aller sur localhost:8000.

Pour ajouter des block dans la chaine, il faut faire:

curl 0.0.0.0:8000/add -d '{"data":"Mon block, Vive les TCOMs"}'

Bonus

Si vous avez tout fait, vous trouverez un fichier yaml openapi.yml qui est une spécification de type OpenAPI qui fonctionne avec swagger.
Vous pouvez tenter de lancer le service en même temps que le reste de l'application.

Clean des images

Si vous voulez clean tout ce qui a été fait sur votre machine depuis le début du TP, vous pouvez:

  • docker system prune: cela permet de supprimer toutes les données de docker qui ne sont plus utilisées.
  • Désinstaller docker.

Aller plus loin

HitCount

Select a repo