Mise en pratique - exemple de A à Z

Support de présentation : https://soleil-docker.jrobert-orleans.fr/06-Ecosysteme_et_Orchestration

Démo complète

Nous allons créer une application avec :

Commençons par un dessin de l’architecture visée.

Ensuite, nous allons créer des images pour chaque service. Nous allons tester ces images indépendamment du reste et noter ce qu’on a fait au format docker compose pour pouvoir ensuite le reproduire.

Je propose cet ordre (qui me semble aller du plus simple au moins simple) qui sera détaillé dans la suite :

  1. Faire tourner le frontend seul en exposant sur le port 8080

  2. Faire tourner une base de données seule, noter son ip,

  3. Faire tourner le backend seul en exposant le port 8081 puis le brancher à la BD (en utilisant son ip)

  4. Ajouter des données à la base de données

Vérifier que tout cela continue de fonctionner avec un docker compose.

On verra ensuite comment ajouter le reverse proxy pour que tout passe par un port unique puis comment sécuriser davantage les réseaux.

Frontend

Cloner le dépôt : git clone https://gitlab.com/formation-docker_jr/frontend.git

Créer une image (docker build -t frontend_exemple) et la lancer.

On peut noter tout ça :

services:
    frontend:
        image: fontend_exemple
        ports:
            - "8080:80"

Base de données

docker run --rm -d -e POSTGRES_PASSWORD=toto --name pgtest postgres

Noter l’ip du conteneur :

docker inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' pgtest

(ici on est un peu embêtés car il n’y a pas comme dans le docker compose la résolution de l’IP par le nom du conteneur)

Backend

D’abord sans docker :

export POSTGRES_SERVER=`docker inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' pgtest`
export POSTGRES_DB="postgres"
export POSTGRES_USER="postgres"
export POSTGRES_PASSWORD="toto"
java -jar target/spring-boot-docker-0.0.1-SNAPSHOT.jar

Puis créer un Dockerfile en utilisant l’image openjdk:11-jdk :

FROM openjdk:11-jdk
COPY target/*.jar /opt/app.jar
ENTRYPOINT ["java","-jar","/opt/app.jar"]

Créez l’image en l’appelant backend_exemple

Ajout de données en BD

Il faudrait exécuter le code sql suivant dans la base de données :

CREATE TABLE utilisateur( nom TEXT );
INSERT INTO utilisateur VALUES ('bonjour');
INSERT INTO utilisateur VALUES ('tout');
INSERT INTO utilisateur VALUES ('le');
INSERT INTO utilisateur VALUES ('monde');

Pour cela, on peut par exemple exécuter postgres directement dans le conteneur pgtest :

docker exec -it pgtest psql -U postgres

Notez qu’on aurait aussi pu lancer un conteneur rien que pour cette opération. Un conteneur qui aurait psql …

Résumé

 version: '3.9'
services:
     frontend:
         image: frontend_exemple
         ports:
             - "8080:80"
     backend:
         image: backend_exemple
         ports:
             - "8081:8080"
         environment: &env_db
           - POSTGRES_SERVER=database
           - POSTGRES_DB=postgres
           - POSTGRES_USER=postgres
           - POSTGRES_PASSWORD=toto
           - PGPASSWORD=toto
     database:
         image: postgres
         environment:
            -  POSTGRES_PASSWORD=toto
     init_db:
         image: postgres
         environment:
           - POSTGRES_SERVER=database
           - POSTGRES_DB=postgres
           - POSTGRES_USER=postgres
           - POSTGRES_PASSWORD=toto
           - PGPASSWORD=toto
           - |
             INIT_DB=
             CREATE TABLE utilisateur( nom TEXT );
             INSERT INTO utilisateur VALUES ('bonjour');
             INSERT INTO utilisateur VALUES ('tout');
             INSERT INTO utilisateur VALUES ('le');
             INSERT INTO utilisateur VALUES ('monde');
         command: sh -c "echo $$INIT_DB | psql -U $$POSTGRES_USER -h $$POSTGRES_SERVER $$POSTGRES_DB"

Améliorations

A partir de là, on peut :

  • segmenter mieux nos réseau

  • indiquer les dépendances entre conteneurs

  • documenter les ports ouverts

  • choisir des versions en dur pour nos conteneurs / paquets

  • relire nos dockerfile pour voir si on applique les bonnes pratiques

  • ajouter un volume pour que la base de données soit persistante

Initialisation base de données

Pour automatiser la création de la base de données plusieurs options sont possibles :

  1. Créer un script coté springboot (cf https://www.baeldung.com/spring-boot-data-sql-and-schema-sql <https://www.baeldung.com/spring-boot-data-sql-and-schema-sql>_)

  2. Créer un script coté base de données (l’image postgres permet de lancer quelque chose au premier lancement)

  3. Utiliser un outil de gestion de migration de base de données (flyway par exemple pour springboot)

  4. Définir un containeur supplémentaire dans le docker compose qui sera lancé sur demande

Pour aller plus loin

Dans la suite, nous pouvons approfondir :

  • l’utilisation de traefik comme reverse proxy

  • l’utilisation de gitlab pour déposer nos images.

  • l’automatisation de la compilation du code (ici le code du serveur devait être compilé à la main avant de pouvoir créer l’image)

  • l’utilisation de gitlab pour la compilation (et le déploiement) automatique des images

Traefik

Avec docker compose, vous avez vu comment déclarer des services, les « scaler », .. Pour pouvoir déployer une application, vous avez croisé haproxy pour :

  • l’équilibrage de charge,

  • un routage qui dépend de la requête http (le conteneur questionné dépend de la route demandée),

  • la gestion des certificats TLS (souvent un peu compliqué) ..

Traefik répond à tous ces besoins, en un peu plus simple.

Vous allez avoir ici un petit aperçu de ce qui est possible et de son fonctionnement.

Écrivez le docker-compose suivant :

services:
  reverse-proxy:
    # The official v2 Traefik docker image
    image: traefik:v2.7
    # Enables the web UI and tells Traefik to listen to docker
    command: --api.insecure=true --providers.docker
    ports:
        # The HTTP port
        - "80:80"
        # The Web UI (enabled by --api.insecure=true)
        - "8080:8080"
    volumes:
        # So that Traefik can listen to the Docker events
        - /var/run/docker.sock:/var/run/docker.sock

Lancez le service reverse-proxy : docker compose up -d reverse-proxy

Ce faisant, vous avez lancé le conteneur traefik qui :

  • écoute sur le port 80 de votre machine et sur le port 8080.

  • a un accès à l’API docker de votre machine (via le montage de /var/run/docker.sock)

Pour le moment, rien d’intéressant sur le port 80. Et sur le port 8080, vous avez une interface de gestion de traefik (rien d’intéressant non plus).

Traefik écoute tout ce qui se passe sur l’API docker et détecte lorsque vous lancez de nouveaux conteneurs. Il regarde alors les labels associés, et se configure en conséquence.

Par exemple, ajoutez les lignes suivantes à votre docker-compose.yml:

whoami:
  # A container that exposes an API to show its IP address
  image: traefik/whoami
  labels:
    - "traefik.http.routers.whoami.rule=Host(`whoami.docker.localhost`)"

Remarquez la partie « labels », elle permettra à traefik de se configurer. Lancez le service : docker compose up -d whoami

Rendez-vous à l’adresse whoami.docker.localhost : le proxy traefik transmet les requêtes qui lui sont faites vers whoami. Pas mal déjà !

Modifiez le service whoami pour qu’il y ait plusieurs répliques :

whoami:
  # A container that exposes an API to show its IP address
  image: traefik/whoami
  labels:
    - "traefik.http.routers.whoami.rule=Host(`whoami.docker.localhost`)"
  deploy:
    replicas: 2

Lancez à nouveau les services. Constatez le fonctionnement « round robin » de l’équilibrage de charge !

Et ce n’est qu’une toute petite partie de ce que permet Traefik..

Pour essayer, mettez en place une application web dont le contenu dynamique est servi par un conteneur et le contenu static est servi par un autre conteneur. Pour cela, vous utiliserez les règles traefik de la forme

"(Host(`example.org`) && Path(`/machin`))"

Voir également :

Traefik dans un compose séparé

Dans le cas où vous avez plusieurs applications dockerisées, vous souhaiterez avoir un reverse proxy commun à toutes les applications.

Pour cela, vous aurez un docker compose pour traefik, puis un docker compose par « application »

services:

  traefik:
    image: "traefik:v2.9"
    container_name: "traefik"
    command:
      #- "--log.level=DEBUG"
      - "--api.insecure=true"
      - "--providers.docker=true"
      - "--providers.docker.exposedbydefault=false"
      - "--entrypoints.web.address=:80"
    ports:
      - "8001:80"
      - "8087:8080"
    volumes:
      - "/var/run/docker.sock:/var/run/docker.sock:ro"
    networks:
      - frontends

networks:
  frontends:
    name: public

Puis par exemple pour une application whoami :

services:
  whoami:
    image: "traefik/whoami"
    container_name: "simple-service"
    networks:
      - public
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.whoami.rule=Host(`whoami.localhost`)"
      - "traefik.http.routers.whoami.entrypoints=web"

networks:
  public:
    external: true

Et pour une deuxième application :

services:
  whoami:
    image: "traefik/whoami"
    container_name: "simple-service"
    networks:
      - public
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.whoami.rule=Host(`deuxieme.localhost`)"
      - "traefik.http.routers.whoami.entrypoints=web"

networks:
  public:
    external: true

Traefik pour le code de la démo précédente

services:
  reverse-proxy:
    # The official v2 Traefik docker image
    image: traefik:v2.7
    # Enables the web UI and tells Traefik to listen to docker
    command: --api.insecure=true --providers.docker
    ports:
        - "80:80"
        - "8080:8080"
    volumes:
        # So that Traefik can listen to the Docker events
        - /var/run/docker.sock:/var/run/docker.sock:ro
    networks:
        - public

  frontend: # <- le nom du conteneur
      image: image_frontend_demo
      ports:
          - "80"
      labels:
        - "traefik.http.routers.frontend.rule=Host(`demo.localhost`) && PathPrefix(`/`)"
      networks:
         - public
  database:
      image: postgres:15
      ports:
          - "5432"
      environment:
          - POSTGRES_PASSWORD=toto
      networks:
         - db_network
  backend:
      image: image_backend_demo
      ports:
          - "8080"
      environment:
        - POSTGRES_SERVER=database
        - POSTGRES_DB=postgres
        - POSTGRES_USER=postgres
        - POSTGRES_PASSWORD=${MACHIN}
      labels:
        - "traefik.http.routers.backend.rule=Host(`demo.localhost`) && PathPrefix(`/api`)"
      networks:
         - public
         - db_network


  init_db:
        image: postgres
        environment:
          - POSTGRES_SERVER=database
          - POSTGRES_DB=postgres
          - POSTGRES_USER=postgres
          - POSTGRES_PASSWORD=toto
          - PGPASSWORD=toto
          - |
            INIT_DB=
            CREATE TABLE utilisateur( nom TEXT );
            INSERT INTO utilisateur VALUES ('bonjour');
            INSERT INTO utilisateur VALUES ('tout');
            INSERT INTO utilisateur VALUES ('le');
            INSERT INTO utilisateur VALUES ('monde');
        command: sh -c "echo $$INIT_DB | psql -U $$POSTGRES_USER -h $$POSTGRES_SERVER $$POSTGRES_DB"
        profiles:
           - tache_administration
        networks:
           - public

networks:
  db_network:
  public:

Bonnes pratiques & Securité

Sécurité

Docker propose un outil de détection de vulnérabilité (Common Vulnerabilities and Exposures -CVEs ) des images. C’est aussi simple que de lancer :

docker scan <nom_de_l_image>

Essayez par exemple en créant une image basée sur nginx:1.18.0 :

FROM nginx:1.18.0

RUN apt update
RUN apt install -y vim

puis en faisant un build :

docker build -t essai_scan .

puis le scan :

docker scan essai_scan

Vous obtiendrez un rapport plus détaillé en spécifiant le Dockerfile utilisé :

docker scan --file Dockerfile <nom_de_l_image>

.dockerignore

Ca ne vous a peut être pas échappé, au moment de lancer un build docker affiche « Uploading build context » . Le client docker à ce moment envoie l’intégralité du répertoire courant au serveur docker. Si ce répertoire est volumineux, cela peut prendre du temps.

Une bonne pratique consiste à écrire un fichier .dockerignore contenant des expressions régulières de fichiers à exclure du contexte de build.

Bonnes pratiques diverses

Ces bonnes pratiques sont tirées de la documentation : https://docs.docker.com/develop/develop-images/dockerfile_best-practices/

  • Each container should have only one responsibility.

  • Containers should be immutable, lightweight, and fast.

  • Don’t store data in your container. Use a shared data store instead.

  • Containers should be easy to destroy and rebuild.

  • Use a small base image (such as Linux Alpine). Smaller images are easier to distribute.

  • Avoid installing unnecessary packages. This keeps the image clean and safe.

  • Avoid cache hits when building.

  • Auto-scan your image before deploying to avoid pushing vulnerable containers to production.

  • Scan your images daily both during development and production for vulnerabilities Based on that, automate the rebuild of images if necessary.

  • apt-get : privilégiez l’installation de paquets sous la forme suivante :

RUN apt-get update && apt-get install -y \
package-bar \
package-baz \
package-foo  \
&& rm -rf /var/lib/apt/lists/*

Isolation des réseaux

Dans le docker compose suivant, on crée 3 services et 2 réseaux. Lancez ce docker compose et vérifiez observez la communication depuis chaque conteneur : * service1 peut-il communiquer (ping) avec service2 ? avec service3 ? avec yahoo.fr ? * Idem avec service2 et service3

services:
service1:
    image: alpine
    command: tail -f /dev/null
    networks:
    - internal_net
    - isolated_net

service2:
    image: alpine
    command: tail -f /dev/null
    networks:
    - internal_net

service3:
    image: alpine
    command: tail -f /dev/null
    networks:
    - isolated_net

networks:
internal_net:
    driver: bridge
isolated_net:
    driver: bridge
    internal: true

Utilisation du registry de gitlab

Nous allons ici voir comment utiliser gitlab pour partager vos images docker.

Prérequis

  1. Créez un acces token pour gitlab

    • Se logguer sur gitlab

    • Choisir « edit profile » en haut à gauche

    • Choisir le menu « Access Token »

    • Donner un nom à votre token (par exemple formation_docker)

    • Choisir « read_repository » et « write_repository »

    • Générer le token et noter le code dans « new personnal access token »

  2. Enregistrer ce token dans docker : docker login

  3. Créez un projet « public » sur gitlab pour les besoins de cette expérimentation

Push/pull image

  1. Créez un fichier Dockerfile contenant :

FROM hello-world

Ensuite :

docker build . -t registry.XXXX.fr/VOTRENOM/NOM_PROJET/image_hello:latest

Puis :

docker push registry.XXXX.fr/VOTRENOM/NOM_PROJET/image_hello:latest

On peut ensuite récupérer l’image sur un autre poste avec :

docker pull registry.XXXX.fr/VOTRENOM/NOM_PROJET/image_hello:latest

et la lancer avec :

docker run  registry.XXXX.fr/VOTRENOM/NOM_PROJET/image_hello:latest
  1. (Bonus) Utilisation du CI/CD: rendez vous sur gitlab puis dans le projet et dans CI/CD -> Pipelines puis choisissez Docker puis validez.

Build multistage

FROM alpine as base
RUN echo mot_de_pass_secret > /opt/mot_de_passe_secret
RUN echo toto > /opt/code_compile
CMD ["sh", "-c", "echo Bonjour depuis base && ls /opt"]

FROM alpine as dev
COPY --from=base /opt/code_compile /opt/
CMD ["sh", "-c", "echo Bonjour depuis dev && ls /opt"]

FROM alpine as prod
COPY --from=base /opt/code_compile /opt/
CMD ["sh", "-c", "echo Bonjour depuis prod && ls /opt"]

FROM alpine as rien
CMD ["sh", "-c", "echo Bonjour depuis rien && ls /opt"]
# syntax=docker/dockerfile:1
FROM node:14-alpine AS builder
WORKDIR /opt/
COPY . .
RUN npm install
RUN npm run build -- --mode production

FROM nginx:1.23
WORKDIR /opt/
COPY --from=builder /opt/dist/ /usr/share/nginx/html/

Pour springboot

# syntax=docker/dockerfile:1
FROM openjdk:11-jdk AS builder
WORKDIR /opt/
COPY . .
RUN ./mvnw package -Dmaven.test.skip=true

FROM openjdk:11-jdk
COPY --from=builder /opt/target/*.jar /opt/app.jar
ENTRYPOINT ["java","-jar","/opt/app.jar"]

Intérêts

  1. Images allégées, permet de ne pas stocker de données sensibles dans l’image, ..

  2. Process de compilation automatisé

  3. Possibilité de viser un « stage » et ainsi avec un seul Dockerfile pouvoir créer des images différentes (par exemple prod / dev).

    • Avec docker build : docker build –target builder

    • Avec docker compose en ajoutant une option “target” à la partie build.