Développement d’exemples et points spécifiques

Support de présentation : https://soleil-docker.jrobert-orleans.fr/06-Ecosysteme_et_Orchestration (et en pdf : 06-Ecosysteme_et_Orchestration-export)

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.

Ecrivez 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 scout cves <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 scout cves essai_scan

Suivez les recommendations pour améliorer la sécurité de votre 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.

Essayez !

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 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

Exercice

Dans l’exemple suivant la personne qui fournit le Dockerfile a pour intention de créer une application qui qui exécute un serveur web Nginx et un serveur SSH pour qu’un utilisateur “user” puisse se connecter en SSH et modifier le contenu du serveur web. Cependant, ce qu’il propose ne respecte pas les bonnes pratiques énoncées ci-dessus.

Essayez de proposer des améliorations.

.
├── Dockerfile
└── index.html
FROM ubuntu:latest
MAINTAINER John Doe <ohn.doe@example.com>
RUN apt-get update
RUN apt-get install -y nginx vim git openssh-server
COPY index.html /var/www/html/index.html
RUN mkdir /run/sshd
RUN useradd -m -s /bin/bash user
RUN echo 'user:password' | chpasswd
EXPOSE 80
EXPOSE 22
ENTRYPOINT ["bash", "-c"]
CMD ["/sbin/sshd && nginx && tail -f /var/log/nginx/access.log"]
version: '3'
services:
  web:
    build: .
    ports:
      - "80:80"
      - "22:22"
    volumes:
      - ./index.html:/var/www/html/index.html

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.

Exercices

Exercice Build multistage GO

  1. Créer une application simple en Go

    • Dans un nouveau dossier, créez un fichier main.go qui met en place un serveur web basique affichant « Hello, World! ».

    • Créez également le fichier go.mod pour la gestion des dépendances.

    go.mod :

    module example.com/hello
    
    go 1.18
    

    main.go :

    package main
    
    import (
        "fmt"
        "log"
        "net/http"
    )
    
    func handler(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "Hello, World! Ceci est une application GO.\n")
    }
    
    func main() {
        http.HandleFunc("/", handler)
        log.Println("Serveur démarré sur http://localhost:8080")
        log.Fatal(http.ListenAndServe(":8080", nil))
    }
    
  2. Créer un Dockerfile naif

    • Pour bien voir la différence, commencez par écrire un Dockerfile simple qui :

      • Part de l’image golang:1.18.

      • Copie le code source.

      • Compile l’application. (CGO_ENABLED=0 GOOS=linux go build -o /app/main .)

      • Expose le port et lance l’exécutable.

    • Construisez cette image et notez sa taille avec docker images.

  3. Modifier le Dockerfile pour utiliser le multi-stage

    • Modifiez votre Dockerfile pour introduire deux étapes :

      • Étape 1 (le `builder`) : Basée sur golang:1.18, cette étape sera responsable de la compilation du code source pour produire un binaire statique.

      • Étape 2 (l’image finale) : Basée sur une image minimale comme alpine:latest ou même scratch (une image vide). Copiez uniquement le binaire compilé depuis l’étape builder dans cette nouvelle étape.

    • La commande finale (CMD) doit lancer ce binaire.

  4. Construire et comparer

    • Construisez la nouvelle image multi-stage.

    • Comparez sa taille avec celle de l’image naïve. La différence devrait être spectaculaire !

    • Lancez un conteneur à partir de l’image optimisée pour vérifier qu’elle fonctionne toujours.

Exercice Environnement de développement JS

  1. Créer une application Node.js simple

    • Dans un nouveau dossier, initialisez un projet Node.js (npm init -y).

    • Installez Express et nodemon (en tant que dépendance de développement) : npm install express et npm install –save-dev nodemon.

    • Créez un fichier app.js qui démarre un serveur Express simple :

      app.js :

      const express = require('express');
      const app = express();
      const port = 3000;
      
      app.get('/', (req, res) => {
      res.send('Hello, Docker Dev World!'); // Modifiez ce message !
      });
      
      app.listen(port, () => {
      console.log(`Application en écoute sur http://localhost:${port}`);
      });
      
    • Ajoutez un script dev dans votre package.json pour lancer l’application avec nodemon :

      package.json :

      ...
      "scripts": {
          ...
          "dev": "nodemon app.js"
      }
      ...
      
  2. Créer le Dockerfile

    • Créez un Dockerfile qui prépare l’image pour la production.

    • Il doit : - Partir d’une image node alpine. - Copier package.json et package-lock.json. - Installer uniquement les dépendances de production (npm ci –only=production). - Copier le reste du code source. - La commande par défaut (CMD) doit lancer l’application avec node (ex: CMD [« node », « app.js »]).

  3. Créer le fichier Docker Compose pour le développement

    • Créez un fichier docker-compose.yml.

    • Définissez un service pour votre application pour le développement :

      • Utilisez la directive build pour construire l’image à partir de votre Dockerfile.

      • Mappez le port de l’application (ex: 3000:3000).

      • Utilisez un volume pour monter le répertoire de votre code source local dans le répertoire de travail du conteneur. Cela écrasera le code copié lors du build.

      • Remplacez la commande par défaut (CMD) avec la commande de développement qui utilise nodemon (ex: command: npm run dev).

  4. Testez !

Exercice Reverse Proxy

  1. Créer deux applications web simples

    • Créez deux dossiers distincts, app1 et app2.

    • Dans chaque dossier, créez une application web très simple qui affiche un message distinct (ex: « Je suis l’application 1 » et « Je suis l’application 2 »). Dans ce chaque dossier, mettez le Dockerfile qui permet de générer l’image de l’application. Par exemple un Dockerfile basé sur Nginx avec une simple page HTML est suffisant.

  2. Créer le fichier Docker Compose

    • À la racine du projet, créez un fichier docker-compose.yml qui définira trois services : traefik, app1, et app2.

    • Service `traefik` : Définissez un service traefik (en suivant la documentation ici https://doc.traefik.io/traefik/expose/docker/)

    • Services `app1` et `app2` :

      • Configurez chaque service pour qu’il se construise à partir de son Dockerfile.

      • N’exposez pas les ports de ces applications sur la machine hôte.

      • Ajoutez des labels Docker à chaque service pour que Traefik sache comment router le trafic vers eux. Vous devrez définir la règle de routage basée sur le nom d’hôte (ex: Host(`app1.fbi.com)`).

  3. Tester le routage

    • Lancez l’ensemble avec docker-compose up.

    • Ouvrez votre navigateur et visitez http://app1.fbi.com et http://app2.fbi.com. Vous devriez voir les pages des applications correspondantes.

    • Visitez http://localhost:8080 pour voir le tableau de bord de Traefik et observer les routes qu’il a créées.