Déploiement d'applications et livraison continue

Contexte et motivation

La livraison continue fait partie des pratiques CI/CD (pour Continuous Integration/Continous Delivery or Deployment) qui sont au coeur des méthodes agiles ou des pratiques DevOps. L'objectif de ces pratiques est de mettre en production les nouvelles fonctionnalités d'une application rapidement et de manière automatisée.

L'intégration continue se centre sur les développeurs en automatisant la validation l'intégration des modifications qu'ils effectuent dans la base de code de l'application. Avec la livraison continue, les nouvelles fonctionnalités sont testées et mises à disposition automatiquement dans un dépôt (par exemple un dépôt git) afin que les équipes d'exploitation puissent les déployer en production. Quant au déploiement continu, il automatise le déploiement en production des applications mises à disposition sur le dépôt.

Dans le cadre des applications déployées dans des conteneurs, le CI/CD s'étend à l'infrastructure elle-même via :

  • La construction automatisée des images Docker
  • La mise à disposition des configurations des services
  • Le déploiement des services basés sur ces configurations
  • La mise à jour des services et de leurs configurations
  • La gestion des configurations (et des secrets...) interne au Swarm Docker
  • ...

Un des problèmes à résoudre pour le déploiement d'application dans différents environnements est la possibilité de fournir des configurations spécifiques pour chacun d'entre eux. Idéalement, ces configurations devraient être versionnées et déployables en utilisant les mêmes outils que le code des applications elles-mêmes. Ce principe est appelé "configuration as code".

Dans ce chapitre, je vais aborder plusieurs techniques, outils et exemples permettant de mettre en place une livraison continue pour des infrastructures Docker Swarm. Je vais en particulier m'intéresser au concept de "configuration as code" et de la manière dont cette technique peut s'appliquer à Docker à travers la composition de fichier Docker Compose ou l'utilisation de variables d'environnement. J'aborderai également les outils Ansible et Gitlab CI.

Déployer les configurations spécifiques à chaque instance avec Docker

Docker propose plusieurs mécanismes qui simplifient le déploiement des configurations :

  • La composition de fichier Docker Compose qui permet d'écraser ou de définir certaines directives de déploiement des piles applicatives
  • Le support des variables et fichiers d'environnement qui permet de définir des valeurs spécifiques à chaque environnement
  • Le support des contextes Docker qui permet de joindre un environnement Docker donné depuis une seule machine
  • Les mécanismes de secrets et configurations qui permettent de spécifier des éléments de configuration directement dans un Swarm Docker pour les mettre à disposition des services déployés

Note : Ansible permet directement d'exécuter des actions spécifiques à certains environnements et ses fichiers peuvent être versionnés. Je vais donc me concentrer sur les mécanismes intégrés à Docker dans cette section.

Composition de fichiers de configuration de services avec docker-compose

Docker Compose permet de diviser la configuration des services en plusieurs fichiers qui seront chargés successivement lors de l’initialisation des conteneurs1. Cela permet, par exemple, d’avoir un fichier générique de déploiement d’application fonctionnant partout mais dans lequel certaines options sont manquantes et d’isoler les modifications nécessaires pour un déploiement « custom » dans un second fichier.

Par exemples, un premier fichier pourrait définir le service de base :

version: "3.7"
services:
  webserver:
    image: nginx:alpine
    volumes:
      - .:/application
      - ./config/nginx-application.conf:/etc/nginx/sites-enabled/default

Et un autre indiquer les ports à utiliser pour ce service :

version: "3.7"
services:
  webserver:
    ports:
      - "8001:80"

Docker Compose charge automatiquement deux fichiers de configuration si rien d’autre n’est précisé :

  • docker-compose.yml : fichier chargé par défaut lors de l’exécution de la commande docker-compose up
  • docker-compose.override.yml : s’il existe, ce fichier sera charger automatiquement à la suite du fichier docker-compose.yml

Note : le nom alternatif compose.yml est également reconnu automatiquement

Mais on peut également en ajouter d’autres, par exemple une configuration pour chaque environnement cible, par exemple :

  • docker-compose.local.yml : pour un déploiement local
  • docker-compose.swarm.yml : pour un déploiement sur un swarm docker
  • docker-compose.staging.yml : pour un déploiement en QA
  • docker-compose.prod.yml : pour un déploiement en production

La composition doit être faite explicitement lors de l’appel à la commande docker-compose avec l’option --file (en raccourci -f) pour lister les fichiers à charger et l’ordre dans lequel les charger :

docker-compose -f docker-compose.yml -f docker-compose.local.yml up -d

ou via la commande docker stack et l’option --compose-file (ou -c en raccourci) sur le cluster

docker stack deploy --compose-file docker-compose.yml -c docker-compose.prod.yml <STACK_NAME>

Notes :

  • Afin de pouvoir être composés, des fichiers Docker Compose doivent impérativement utiliser le même numéro de version
  • Certaines directives des fichiers Docker Compose peuvent être étendues, d'autres ne peuvent être redéfinies2.

Docker et les variables d'environnement

Docker prend en compte les variables d’environnement. Celles-ci peuvent être passées aux commandes Docker ou via les fichiers Docker Compose soit via des variables individuelles, soit en utilisant des fichiers de variables d'environnement.

L'intérêt des variables d'environnement est de créer des fichiers de déploiement (Docker Compose, Dockerfile...) génériques et de définir les valeurs spécifiques à chaque déploiement sur la machine hôte.

Notes :

  • La syntaxe est la même que pour les variables shell : ${<VAR_NAME>:-<DEFAULT_VALUE>}
  • Si un fichier .env existe dans le répertoire courant, il est chargé automatiquement par Docker
  • Les variables d'environnement peuvent également être accédées dans les fichiers Dockerfile avec la syntaxe $VAR_NAME et passé via les options -e VAR_NAME=VAR_VALUE ou --env-file /path/to/env-file de la commande de construction d'image ou via le fichier Docker Compose.

Docker Secrets and Docker Config

Docker Swarm fournit deux mécanismes permettant de mettre à disposition des configurations partagées entre conteneurs :

  • Pour les fichiers de configuration simple, ils peuvent être mis à disposition via la commande docker config3 et ensuite montés dans un service d'une manière similaire à n'importe quel volume ou fichier.
  • Pour les fichiers contenant des données confidentielles (clés, certificats X509 ou SSH, credentials...), il est préférable d'utiliser la commande docker secret4 qui permet de les stocker de manière sécurisée (ils sont chiffrés dans les journaux de RAFT et transmis vers les managers via une connexion TLS).

Voici un court exemple d'utilisation des configurations et secrets pour fournir les informations nécessaires à un proxy nginx :

version: "3.9"

# ...

services:
  proxy:
    image: nginx:latest
    
    # ...

    configs:
      - source: nginx_default_site
        target: /etc/nginx/sites/default.conf
        mode: 0440
      - source: nginx_shared_proxy
        target: /etc/nginx/includes/proxy.conf
        mode: 0440
    secrets:
      - source: ssl_cert
        target: /etc/ssl/certs/proxy.pem
      - source: ssl_interm
        target: /etc/ssl/certs/proxy_interm.cer
      - source: ssl_private_key
        target: /etc/ssl/private/proxy.key
    
    # ...

configs:
  nginx_default_site:
    file: ./config/nginx/sites/default.conf
  nginx_shared_proxy:
    external: true

secrets:
  ssl_cert:
    external: true
  ssl_interm:
    external: true
  ssl_private_key:
    external: true

# ...

Note : Les secrets et configs Docker sont une raison pour laquelle il peut être intéressant de déployer Docker Swarm même pour une infrastructure avec un seul noeud, plutôt que de se contenter de Docker Compose.

Exemples

Voici deux exemples qui illustrent l'utilisation de ces mécanismes pour le déploiement de piles applicatives.

Déploiement d'une pile Wordpress

L'exemple suivant combine variables définies dans l'environnement du shell qui exécute la commande de déploiement (par exemple via un fichier .env) et fichiers d'environnement dans un même fichier Docker Compose, afin de déployer une instance de Wordpress:

version: '3.7'

volumes:
  dbdata:
  wpdata:

networks:
  net1:

services:
  mysql:
    image: mysql:${MYSQL_VERSION:-5.7}
    env_file:
      - db.env
    networks:
      - net1
    volumes:
      - type: volume
        source: dbdata
        target: /var/lib/mysql

  pma:
    image: phpmyadmin:${PMA_VERSION:-latest}
    networks:
      - net1
    env_file:
      - pma.env

  wordpress:
    image: wordpress:${WP_VERSION:-latest}
    networks:
      - net1
    volumes:
      - type: volume
        source: wpdata
        target: /var/www/html
    env_file:
      - wp.env

Le fichier d'environnement correspondant est :

# db.env
MYSQL_ROOT_PASSWORD=secret
MYSQL_DATABASE=wp1db
MYSQL_USER=wp1user
MYSQL_PASSWORD=wp1pass

Les variables passées peuvent également être utilisées dans les conteneurs eux-mêmes. Voici un exemple d'une telle utilisation dans l'entrypoint d'un service pour déployer la plateforme Moodle :

# entrypoint.sh
#!/bin/bash

# Configuration de Moodle à partir du template
echo "[$0]:creation du fichier de config."

sed \
    -e "s,MOODLE_WWWROOT,${MOODLE_URL:-http://127.0.0.1:8000}," \
    -e "s,MOODLE_LANG,${MOODLE_LANG:-fr_FR}," \
    -e "s,MOODLE_DBTYPE,${MOODLE_DBTYPE:-pgsql}," \
    -e "s,MOODLE_DB_HOST,${MOODLE_DB_HOST:-db}," \
    -e "s,MOODLE_DB_NAME,${MOODLE_DB_NAME:-moodle}," \
    -e "s,MOODLE_DB_USER,${MOODLE_DB_USER:-moodleuser}," \
    -e "s,MOODLE_DB_PASSWORD,${MOODLE_DB_PASSWORD:-moodlepass}," \
    -e "s,MOODLE_DB_TABLE_PREFIX,${MOODLE_DB_TABLE_PREFIX:-mdl_}," \
    /tmp/config-dist.php > /var/www/html/config.php

exec "$@"

Cet exemple utilise sed afin de replacer des chaînes dans un template de configuration :

# config-dist.php

// ...

$CFG->lang    = 'MOODLE_LANG'; 

$CFG->dbtype    = 'MOODLE_DBTYPE';      // 'pgsql', 'mariadb', 'mysqli', 'auroramysql', 'sqlsrv' or 'oci'
$CFG->dblibrary = 'native';     // 'native' only at the moment
$CFG->dbhost    = 'MOODLE_DB_HOST';  // eg 'localhost' or 'db.isp.com' or IP
$CFG->dbname    = 'MOODLE_DB_NAME';     // database name, eg moodle
$CFG->dbuser    = 'MOODLE_DB_USER';   // your database username
$CFG->dbpass    = 'MOODLE_DB_PASSWORD';   // your database password
$CFG->prefix    = 'MOODLE_DB_TABLE_PREFIX';       // prefix to use for all table names

// ...

Déploiement des piles Drupal pour le Portail

Dans la cadre du developpement du nouveau portail Drupal, j'ai été amené à résoudre plusieurs contraintes techniques liées au déploiement d'application. J'intègre cette problématique ici car sa résolution pourrait être appliquée à d'autres piles applicatives.

Les contraintes étaient :

  1. Le déploiement dans des environnements multiples
  2. Le déploiement sur des environnements d'exécution Docker différents
Déploiement dans des environnements multiples

La pile applicative du portail devait pouvoir être déployée dans des environnements de test (sur les machines locales des développeurs), des instances spécifiques à certains usages (test, formation, migration, démonstration...) et, bien entendu, la pré-production (QA) et la production. Chacun de ces environnements avait ses spécificités quant à la configuration de la pile applicative.

En particulier, la topologie de la pile (cluster avec 1 ou plusieurs noeuds), localisation de la base de donnée (dans la pile applicative ou sur un hôte distant), le montage de volume supplémentaires pour les migrations...

Déploiement sur des environnements d'exécution différents

Afin de répondre tant aux besoins des développeurs que de prestataires externes, les piles ont dû être rendues compatibles avec les environnements d'exécution Docker Compose, utilisé sur les machines locales sous MacOSX ou Windows, et Docker Swarm, utilisé sur les clusters distants ou sur les machines locales sous Linux.

Variables d'environnements et composition de piles Docker

J'ai implémenté la prise en compte de ces contraintes en utilisant 2 mécanismes : la composition de fichiers docker-compose de description des piles applicatives Docker (déjà abordée plus haut), et le mécanisme de variables d'environnement.

L'idée centrale était de décrire les spécificités de chaque déploiement dans un fichier de variables UNIX .env situé à la racine.

En voici un exemple pour un déploiement du portail sur un cluster Docker Swarm local derrière un reverse proxy nginx-proxy :

# Environnement d'exécution Docker (valeurs autorisées : swarm ou compose)
MODE=swarm
# Topologie de l'environnement Docker (ici mono nœud, valeurs autorisées mono ou multi)
SWARM_TOPO=mono
# Fichier de description de plie Docker à utiliser, dans ce cas un environnement utilisant nginx-proxy
SWARM_ENV=nginx-proxy

# Variable spécifiques à l'installation de l'application
BRANCH=develop
PORTAL_MODE=dev
PORTAL_INSTANCE=dev
POSTGRES_DB=drupal
POSTGRES_USER=drupal
POSTGRES_PASSWORD=password
DB_HOST=database
DB_PORT=5432
DRUPAL_PASSWORD=password

# Variables spécifiques à nginx-proxy
PORTAL_VHOST=portal.docker.localhost
PORTAL_VPROTO=http
PORTAL_VPORT=80

J'ai créé pour la pile applicative un fichier docker-compose.yml de base reprenant les directives et options communes à tous les environnements.

version: '3.7'
services:
  webserver:
    image: nginx:latest
    depends_on:
      - "php-fpm"
    command: ["/bin/wait-for-it.sh", "php-fpm:9000", "--", "nginx", "-g", "daemon off;"]
    env_file:
      - docker/nginx/nginx.env
      - .env
    working_dir: /var/www/portail-next
    volumes:
      - ./var/www/portail-next:/var/www/portail-next/
      - ./docker/wait-for-it.sh:/bin/wait-for-it.sh
      - ./docker/nginx/conf.d:/etc/nginx/conf.d
      - ./docker/nginx/sites:/etc/nginx/sites-enabled
      - ./docker/nginx/nginx.conf:/etc/nginx/nginx.conf
  php-cli:
    image: private-registry:5000/drupal_phpfpm81:latest
    build:
      context: ./docker/php-fpm
      dockerfile: dockerfile
      cache_from:
        - private-registry:5000/drupal_phpfpm81:latest
    env_file:
      - docker/php-fpm/php-fpm.env
      - .env
    working_dir: /var/www/portail-next
    volumes:
      - ./docker/php-fpm/php-ini-overrides.ini:/etc/php/8.1/fpm/conf.d/99-overrides.ini
      - ./var/www/portail-next:/var/www/portail-next/
      - ./bin:/var/www/portail-next/bin
    command: ["tail", "-f", "/dev/null"]
  php-fpm:
    image: private-registry:5000/drupal_phpfpm81:latest
    build:
      context: ./docker/php-fpm
      dockerfile: dockerfile
      cache_from:
        - private-registry:5000/drupal_phpfpm81:latest
    env_file:
      - docker/php-fpm/php-fpm.env
      - .env
    working_dir: /var/www/portail-next
    volumes:
      - ./docker/php-fpm/php-ini-overrides.ini:/etc/php/8.1/fpm/conf.d/99-overrides.ini
      - ./var/www/portail-next:/var/www/portail-next/
      - ./bin:/var/www/portail-next/bin

Ensuite, pour chaque valeur d'environnement spécifique SWARM_ENV et chaque topologie SWARM_TOPO, j'ai fourni un fichier environments/<SWARM_ENV>/docker-compose.<SWARM_TOPO>.yml contenant les directives et options spécifiques à chaque environnement.

# environments/prod/docker-compose.multi.yml
version: '3.7'
services:
  webserver:
    deploy:
      mode: global
      placement:
        constraints:
          - node.role!=manager
    networks:
      - portail_ntwrk
      - proxy_public
    volumes:
      - /portail-data:/var/www/portail-next/web/sites/default/files/private
  php-cli:
    deploy:
      replicas: 1
      placement:
        constraints:
          - node.role==manager
    networks:
      - portail_ntwrk
    volumes:
      - /portail-data:/var/www/portail-next/web/sites/default/files/private
  php-fpm:
    deploy:
      mode: global
      placement:
        constraints:
          - node.role!=manager
    networks:
      - portail_ntwrk
    volumes:
      - /portail-data:/var/www/portail-next/web/sites/default/files/private
networks:
  portail_ntwrk:
    driver: overlay
    attachable: true
  proxy_public:
    name: proxy_public
    external: true

Enfin, j'ai créé une commande Bash (mais cela aurait pu être fait avec une commande ansible également) permettant de créer les piles applicatives, les démarrer et les arrêter en se basant sur les variables d'environnement définies pour sélectionner les bons fichiers de définition de pile.

En pratique, la personne devant déployer l'application n'a plus qu'à définir les variables correspondant à l'environnement sur lequel il ou elle effectue le déploiement dans un fichier nommé .env et situé à la racine du répertoire de son instance du portail. Il lui suffit ensuite d'exécuter le script permettant la création de la pile et de lancer celle-ci.

Le cas de Docker Compose est un peu particulier, puisqu'une personnalisation du fichier docker-compose.override.yml est nécessaire. Ce fichier docker-compose.override.yml doit également se trouver à la racine du répertoire de l'application à déployer.

Généralisation

Bien que créé spécifiquement pour le portail, ce mécanisme peut être généralisé avec un minimum d'effort et intégrer à d'autres piles applicatives. Il pourrait également être transformé en un ensemble de tâches Ansible ou être appelé par le mécanisme de livraison continue.

Déploiement d’applications avec Ansible et Docker-Compose

Une des solutions pour automatiser les opérations de déploiement de conteneurs consiste à utiliser Ansible et Docker Compose.

Automatisation de déploiement d’application avec Ansible : un exemple avec Kanboard, une application PHP élémentaire

Un premier cas de déploiement a été décrit plus haut avec l'application Kanboard. Il s’agissait d’un cas élémentaire ne demandant aucune installation autre que le téléchargement de l’application et le déploiement des services de la stack. La seule particularité était la gestion des droits sur le répertoire des données de l’application.

Afin de pouvoir fonctionner, cette application demande des extensions particulières pour le service php-fpm.

Celles-ci sont fournies par le générateur de squelette de déploiement docker-compose PhpDocker.io. Ce site permet de créer des stacks contenant la version de PHP, extensions et services tiers (bases de données, mail…) requis par une application PHP.

Fichier de configuration de services

Fichier docker-compose.yml :

version: "3.1"
services:

    webserver:
      image: nginx:alpine
      container_name: kanboard-webserver
      working_dir: /application
      volumes:
          - ./kanboard:/application
          - ./docker/nginx/nginx.conf:/etc/nginx/conf.d/default.conf

    php-fpm:
      build: docker/php-fpm
      container_name: kanboard-php-fpm
      working_dir: /application
      volumes:
        - ./kanboard:/application
        - ./docker/php-fpm/php-ini-overrides.ini:/etc/php/7.4/fpm/conf.d/99-overrides.ini

Fichier docker-compose.local.yaml :

version: "3.1"
services:

  webserver:
    ports:
      - "8001:80"

Fichier de construction de l’image php-fpm

FROM phpdockerio/php74-fpm:latest
WORKDIR "/application"

# Fix debconf warnings upon build
ARG DEBIAN_FRONTEND=noninteractive

# Install selected extensions and other stuff
RUN apt-get update \
    && apt-get -y --no-install-recommends install  php7.4-sqlite3 php7.4-bcmath php7.4-gd php7.4-intl php7.4-ldap php7.4-xsl php-yaml \
    && apt-get clean; rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* /usr/share/doc/*

# Install git
RUN apt-get update \
    && apt-get -y install git \
    && apt-get clean; rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* /usr/share/doc/*

Playbook de déploiement avec Ansible

---
- name: "Deploying Kanboard application locally"
  hosts: localhost
  connection: local
  tasks:

    - name: Check if kanboard folder exists
      stat:
        path: ../kanboard
      register: kanboard_folder

    - name: Get Kanboard
      git:
        repo: https://github.com/kanboard/kanboard.git
        dest: ../kanboard
      when: kanboard_folder.stat.exists == false

    - name: Check if kanboard db exists
      stat:
        path: ../kanboard/data
      register: kanboard_data

    - name: Change file ownership, group and permissions
      file:
        path: ../kanboard/data
        state: directory
        recurse: yes
        mode: 0777
      when: kanboard_data.stat.mode != 0777

    - name: Check if docker-compose.local file exists
      stat:
        path: ../docker-compose.local.yml
      register: local_compose

    - name: Deploy application containers with local compose file
      args:
        chdir: ../
      shell: docker-compose -f docker-compose.yml -f docker-compose.local.yml up -d
      when: local_compose.stat.exists == true

    - name: Deploy application containers with default compose file
      args:
        chdir: ../
      shell: docker-compose up -d
      when: local_compose.stat.exists == false

Ce fichier automatise toutes les tâches nécessaires pour déployer l’application :

  • récupération du code de l’application depuis un dépôt distant
  • modifications de droits d’accès sur le répertoire des données de kanboard
  • déploiement de l’application via docker-compose

Le déploiement de l’application en local se fait via la commande ansible-playbook :

ansible-playbook --connection=local playbooks/deploy.yml

Pour aller plus loin : gestion des droits d’accès pour un déploiement sur le cluster

Ce qui précède fourni le nécessaire pour déployer une application sur une machine locale. Toutefois, son déploiement sur le cluster Swarm demande une petite modification au niveau des droits d’accès sur les répertoires afin de correspondre aux ACL définies sur le cluster.

La modification est relativement simple : il suffit d’utiliser le groupe 'docker' pour l’accès aux fichiers. Toutefois, ce groupe n’existant pas dans notre conteneur php-fpm, une modification du fichier Dockerfile est nécessaire pour l’ajouter :

FROM phpdockerio/php74-fpm:latest
WORKDIR "/application"

# Fix debconf warnings upon build
ARG DEBIAN_FRONTEND=noninteractive

# Install selected extensions and other stuff
RUN apt-get update \
    && apt-get -y --no-install-recommends install  php7.4-sqlite3 php7.4-bcmath php7.4-gd php7.4-intl php7.4-ldap php7.4-xsl php-yaml \
    && apt-get clean; rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* /usr/share/doc/*

# Install git
RUN apt-get update \
    && apt-get -y install git \
    && apt-get clean; rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* /usr/share/doc/*

# Ligne ajoutée pour créer le groupe docker dans le conteneur
# Attention le numéro du groupe dépend de la distribution
RUN groupadd --gid 999 docker

Il faudra également modifier le fichier de configuration du pool de php-fpm docker/php-fpm/php-ini-overrides.conf afin d’utiliser le groupe 'docker' au lieu le groupe 'www-data' utilisé par défaut.

Pour cela, il suffit de remplacer

[www]
user = www-data
group = www-data

par

[www]
user = www-data
group = docker

Il reste encore à modifier de la tâche de modification des droits d’accès dans le playbook de déploiement :

- name: Change file ownership, group and permissions
      file:
        path: ../kanboard/data
        state: directory
        group: docker
        recurse: yes
        mode: 0775
      when: kanboard_data.stat.gr_name != 'docker'

Déploiement sur le swarm

version: "3.1"
services:

    redis:
      image: redis:alpine
      container_name: framemo-redis
      deploy:
        replicas: 1
        placement:
          constraints:
            - node.role!=manager

    webserver:
      image: nginx:alpine
      container_name: framemo-webserver
      working_dir: /application
      volumes:
        - ./framemo:/application
        - ./config/nginx/default.conf:/etc/nginx/conf.d/default.conf
        - ./wait-for-it.sh:/usr/local/bin/wait-for-it.ch
      command: ["bash /usr/local/bin/wait-for-it.sh", "framemo-nodejs:3000", "--", "nginx", "-g", "daemon off;"]
      deploy:
        replicas: 1
        placement:
          constraints:
            - node.role!=manager
      depends_on:
        - nodejs

    nodejs:
      image: dkm-webapps.sipr-dc.ucl.ac.be:5000/framemo_node:latest
      build:
        context: .
        cache: dkm-webapps.sipr-dc.ucl.ac.be:5000/framemo_node:latest
      container_name: framemo-nodejs
      working_dir: /application
      volumes:
          - ./framemo:/application
          - /application/node_modules
          - ./config/framemo/config.js:/application/config.js
      command: node /application/server.js --port 3000
      deploy:
        replicas: 1
        placement:
          constraints:
            - node.role!=manager

Le script wait-for-it.sh permet d’attendre qu’un service soit disponible avant d’exécuter une commande. C’est particulièrement important pour éviter une erreur lors du démarrage du service nginx. Ce script est disponible dans les fichiers techniques ou sur github.

- name: "Deploying framemo application on a swarm"
  hosts: localhost
  connection: local
  tasks:

    - name: Check if framemo folder exists
      stat:
        path: ../framemo
      register: framemo_folder

    - name: Get framemo
      git:
        repo: https://framagit.org/framasoft/framemo.git
        dest: ../framemo
      when: framemo_folder.stat.exists == false

    - name: Check if docker-compose.swarm file exists
      stat:
        path: ../docker-compose.swarm.yml
      register: swarm_compose

    - name: Deploy application containers with swarm compose file
      args:
        chdir: ../
      shell: docker stack deploy -c docker-compose.yml -c docker-compose.swarm.yml framemo
      when: swarm_compose.stat.exists == true

Analyses de cas

Framemo, une application nodejs + redis

L’application Framemo est une version modifiée mise à disposition par le collectif Framasoft de l’application de post-it en ligne Scrumblr. Son code source est disponible via le service gitlab framagit.org.

Il s’agit d’une application Nodejs relativement simple utilisant un serveur Redis pour le stockage de ses données. Elle utilise également le gestionnaire de paquet npm pour son installation. J’ai décidé d’ajouter un proxy Nginx à la stack, mais ce n’est pas nécessaire. En effet, le service nodejs de la stack pourrait servir de point d’entrée.

J’ai choisi cet exemple pour deux raisons :

  1. les exemples simples de déploiement d’application Nodejs avec docker-compose sont plutôt rares
  2. l’installation des paquets via le gestionnaire de paquet npm lors de la construction de l’image docker du micro-service de l’application

Les principales contraintes que je m’étais fixées pour cet exemple sont :

  • utiliser des images disponibles sur le hub de Docker
  • ne pas exécuter la commande npm sur la machine hôte, mais bien dans son conteneur
  • ne pas modifier les fichiers de l’application récupérés depuis gitlab
Configuration des services de l’application

Le fichier docker-compose.yml est le suivant :

version: "3.1"
services:

    redis:
      image: redis:alpine
      container_name: framemo-redis

    webserver:
      image: nginx:alpine
      container_name: framemo-webserver
      working_dir: /application
      volumes:
        - ./framemo:/application
        - ./config/nginx/default.conf:/etc/nginx/conf.d/default.conf
      depends_on:
        - nodejs

    nodejs:
      image: framemo_node
      build: .
      container_name: framemo-nodejs
      working_dir: /application
      volumes:
          - ./framemo:/application
          - /application/node_modules
          - ./config/framemo/config.js:/application/config.js
      command: node /application/server.js --port 3000

Remarques :

  • Les noms des conteneurs des services ont été fixés dans le fichier pour faciliter le déploiement. Dans le cas contraire, docker-compose attribue automatiquement comme nom le nom du répertoire suivi d'un "_" puis du non du service. Il est tout à fait possible de laisser docker le faire, mais dans ce cas des variables et des templates devront être utilisés pour les configurations des services et applications de la stack.
  • A part en ce qui concerne nodejs, cette stack utilise uniquement des images officielles disponibles depuis le hub de docker.
  • Le port utilisé pour servir l'application Framemo a été modifié via --port 3000, mais le port par défaut de l’application (8080) aurait pu être utilisé.

Un second fichier permet de modifier le port exposé par le serveur web Nginx afin de ne pas avoir de collision sur le port 80 :

version: "3.1"
services:

    webserver:
      ports:
       - "8002:80"
Playbook Ansible pour un déploiement local

Le playbook de déploiement via Ansible est assez simple dans le cas de cette application. Il est responsable des actions suivantes :

  • récupérer le code de l’application depuis son dépôt gitlab
  • lancement de la stack avec les fichiers de configuration docker-compose disponibles

Pour un déploiement local, le fichier est le suivant :

---- name: "Deploying framemo application locally"  hosts: localhost  connection: local   tasks:      - name: Check if framemo folder exists      stat:         path: ../framemo      register: framemo_folder    - name: Get framemo      git:        repo: https://framagit.org/framasoft/framemo.git        dest: ../framemo      when: framemo_folder.stat.exists == false        - name: Check if docker-compose.local file exists      stat:         path: ../docker-compose.local.yml      register: local_compose        - name: Deploy application containers with local compose file      args:        chdir: ../      shell: docker-compose -f docker-compose.yml -f docker-compose.local.yml up -d      when: local_compose.stat.exists == true        - name: Deploy application containers with default compose file      args:        chdir: ../      shell: docker-compose up -d      when: local_compose.stat.exists == false
Build de l’image pour nodejs

Un build de l’image utilisée pour Nodejs est nécessaire à la récupération des paquets npm nécessaires à notre application via la commande npm install.

Les volumes montés pour le build par docker-compose sont les suivants :

  • ./framemo:/application : le répertoire de l’application
  • /application/node_modules : pour récupérer les paquets installés par npm
  • ./config/framemo/config.js:/application/config.js : pour personnaliser la configuration de l’application afin qu’elle puisse fonctionner dans la stack

Pour construire l’image, j’ai décidé d’utiliser une image node:alpine sur laquelle sera exécutée l’installation des paquets via npm.

Le fichier Dockerfile correspondant est :

# On part d’une image disponible sur le hub docker
FROM node:alpine

# Le répertoire de travail est le volume de l’application déclaré
# dans le fichier docker-compose.yml
WORKDIR /application

# On copie le fichier conteant la liste des dépendances de l’application
# ATTENTION le chemin est relatif au fichier Dockerfile !
COPY ./framemo/package.json .

# Exécution de la commande d’installation des paquets, ceux-ci seront installés dans
# /application/node_modules
RUN npm install --quiet

# Copie des fichiers récupérés afin de les rendre disponibles hors de l’image docker
COPY . .

Notes :

  • Ce fichier est inspiré par un exemple trouvé en ligne, toutefois aucun de ces exemples ne répondait aux contraintes et aux spécificités de l’application Framemo

  • Le chemin pour intégrer le fichier package.json est relatif au fichier Dockerfile, j’ai décidé de placer celui-ci à la racine du projet de déploiement

  • Pour permettre l’installation des paquets npm et leur accès sur le filesystem de l’hôte, il a fallu ajouter le montage /application/node_modules au fichier docker-compose.yml

Configuration de l’application et de nginx

Une modification de la configuration de l'application est nécessaire afin de pouvoir accéder au service Redis de la stack. Elle consiste à simplement remplacer dans une copie de la configuration originale la ligne

var redis_conf = argv.redis || '127.0.0.1:6379';

par

var redis_conf = argv.redis || 'framemo-redis:6379';

framemo-redis est le nom interne utilisé par docker pour le routage des requêtes réseau vers le service redis au sein de la stack.

L’application peut alors être lancée via la commande node /application/server.js --port 3000dans le fichier docker-compose.yml.

Le choix du port est arbitraire et correspond à celui indiqué dans la configuration du serveur web Nginx :

server {
    listen 80 default;

    client_max_body_size 108M;

    access_log /var/log/nginx/framemo.access.log;

    root /application;

    location / {
        proxy_pass http://framemo-nodejs:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_cache_bypass $http_upgrade;
    }

}

framemo-nodejs est le nom interne utilisé par docker pour le routage des requêtes réseau vers le service nodejs au sein de la stack.

Déploiement de l’application

Le déploiement de l’application en local se fait via la commande ansible-playbook :

ansible-playbook --connection=local playbooks/deploy.yml

Dont le résultat est

 [WARNING]: provided hosts list is empty, only localhost is available. Note that the implicit localhost does not match
'all'


PLAY [Deploying framemo application locally] ***************************************************************************

TASK [Gathering Facts] *************************************************************************************************
ok: [localhost]

TASK [Check if framemo folder exists] **********************************************************************************
ok: [localhost]

TASK [Get framemo] *****************************************************************************************************
skipping: [localhost]

TASK [Check if docker-compose.local file exists] ***********************************************************************
ok: [localhost]

TASK [Deploy application containers with local compose file] ***********************************************************
changed: [localhost]

TASK [Deploy application containers with default compose file] *********************************************************
skipping: [localhost]

PLAY RECAP *************************************************************************************************************
localhost                  : ok=4    changed=1    unreachable=0    failed=0

Il suffit alors de se rendre sur l'url http://localhost:8002 pour accéder à l’application.

Autres applications

  • Le CMS Grav
  • Une application d’exemple basée sur Nodejs + Yarn

Optimisation de la taille des conteneurs et build multi-step

Les fichiers Dockerfile permettent de définir des recettes de construction d'image en plusieurs étapes. C'est en particulier utile lorsqu'une image est nécessaire pour construire une application, mais qu'une autre image sera utilisée pour exécuter l'application. C'est typiquement le cas des applications web basées sur Node.js (Angular, React...)

# Image Node.js 16 pour construire l'application React
FROM node:16-bullseye-slim as builder

RUN set -ex \
    && apt-get update && apt-get install -y git ca-certificates --no-install-recommends \
    && apt-get clean && rm -rf /var/lib/apt/lists/*

RUN set -ex \
    && mkdir -p /opt/fari \
    && git clone https://github.com/fariapp/fari-app.git /opt/fari \
    && cd /opt/fari \
    && npm install

RUN apt-mark auto '.*' > /dev/null \
    && find /usr/local -type f -executable -exec ldd '{}' ';' \
      | awk '/=>/ { print $(NF-1) }' \
      | sort -u \
      | xargs -r dpkg-query --search \
      | cut -d: -f1 \
      | sort -u \
      | xargs -r apt-mark manual \
    && apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false

RUN set -ex \
    && cd /opt/fari \
    && npm run build

# Image nginx permettant de servir l'application
FROM nginx:1.23.1

COPY --from=builder /opt/fari/dist /usr/share/nginx/html

Dans cet exemple, l'image node a laquelle j'ai attribué le nom builder (via FROM ... as builder) n'est utilisée que pour récupérer le code source de l'application depuis github et construire celle-ci. Une fois cette étape effectuée, les fichiers générés sont copiés dans une image basée sur nginx via COPY --from=builder. A la fin du build, seule cette seconde image sera conservée. Dans le cas de cette application, l'image de build pèse 1.92Go alors que l'image finale ne pèse que 135Mo !

Un autre exemple est le fichier que j'utilise pour déployer la version web de ce brevet :

# Image Rust temporaire permettant de construire l'application à déployer
FROM rust:1.67 AS builder

RUN cargo install mdbook mdbook-toc mdbook-mermaid

WORKDIR /opt/src
COPY . .
RUN mdbook build

# Image nginx qui permettra de servir l'application construite
FROM nginx:latest

COPY --from=builder /opt/src/book/html /usr/share/nginx/html

Ici le builder est une image contenant l'environnement d'exécution du langage Rust et installant d'installer l'outil mdbook et ses modules supplémentaires. Mdbook exécutent ensuite la génération du site web à partir des fichiers markdown de mon brevet. Les fichiers générés sont alors de nouveau copiés dans un conteneur nginx.

Note :

  1. le backend buildkit, utilisé par défaut depuis la version 23 de Docker mais disponible sur les versions précédentes, permet un plus grand contrôle sur les build multi-step. Il est par exemple possible de générer l'image d'une étape donnée d'un fichier Dockerfile multi-step.
  2. le processus de build ne gardera que la dernière image générée par défaut. En cas de build fréquent basé sur une même image, il est conseillé de télécharger cette image via docker image pull afin d'éviter de gaspiller de la bande passante et du temps de téléchargement inutile. Les images rust:1.67 ou node:16-bullseye-slim de mes deux exemples font chacune plus de 1Go.

Livraison continue avec Gitlab CI

La livraison continue est un mécanisme issu des pratiques DevOps et qui consiste à déployer une application lorsqu’une nouvelle version prête pour la production est disponible.

Ce mécanisme se distingue du déploiement continu par le fait que l’action est déclenchée ici à la demande des développeurs et non de manière automatique à chaque changement de la base de code de l’application. Le déploiement continu est généralement combiné avec l’intégration continue qui consiste, en gros, à valider les changements effectués sur une application sur base de tests automatisés. Le déploiement continu ne s’effectuera donc que si ces tests ont réussi.

La livraison continue permet donc d’avoir un plus grand contrôle sur le produit déployé et est plus sécurisant pour des applications critiques.

Dans la livraison continue, le déploiement d’une application peut être déclenché par plusieurs mécanismes comme l’exécution d’une commande depuis un outil d’automatisation ou sur base d’un événement comme le changement poussé sur une branche donnée dans le dépôt de son code source.

On distingue deux stratégies de déploiement pour mettre en place un mécanisme de livraison continue :

  • Push-based : dans cette première stratégie, c’est une modification du code source d’une application qui va déclencher l’exécution des tâches de déploiement. C’est le mécanisme le plus souvent rencontré. Il est utilisé par Gitlab-CI, Jenkins, Travis CI… Le défaut de cette approche est que l’application ne sera déployée que si le code de l’application est modifié. Les changements sur les images Docker ne seront quant à eux pas pris en compte automatiquement.
  • Pull-based : dans ce cas de figure, un agent (appelé opérateur) de l’environnement de déploiement va observer le dépôt du code source de l’application ainsi que le dépôt des images. Il déclenchera le déploiement s’il détecte une modification de l’un d’entre eux. C’est le fonctionnement mis en œuvre par GitOps, par exemple.

Afin de mettre en place ce mécanisme, j’ai choisi d’utiliser les outils suivants :

  • Gitlab comme dépôt pour les fichiers de déploiement des applications et, lorsqu’il s’agit d’un développement interne, leur code source
  • Gitlab-CI est le moteur d'exécution des tâches d'intégration continue, livraison continue et déploiement continu (CI/CD) de Gitlab
  • Gitlab-Runner est l'outil qui permet l'exécution des taches de CI/CD sur les machines distantes

Note : Gitlab intègre un registre d'images Docker qui pourra être utilisé comme registry externe pour le Swarm. Le registre a été activé sur la Forge UCLouvain et sur le GItlab de SISG. Le mécanisme contient deux parties :

  • Un service gitlab-runner doit être déployé sur la machine qui exécutera les tâches de déploiement
  • Ce service doit être configuré dans les runners disponibles sur Gitlab. Un runner peut être soit shared pour l'ensemble des projets hébergés sur l'instance Gitlab, soit dédié à un groupe de projets ou à un projet spécifique.

L'enregistrement d'un runner se fait via l'interface de Gitlab, chaque runner se voit associé un token qui l'identifie et un autre qui permet au service gitlab-runner de s'enregistrer auprès de gitlab.

Les opérations à exécuter sont définies dans une pipeline. Les différentes tâches (jobs) seront exécutées par le runner. Les jobs d'une pipeline peuvent être traités en parallèles et exécutés par des runners différents (par exemple un runner chargé de construire l'application, un autre chargé de son déploiement). Les pipelines sont définis dans un fichier YAML nommé .gitlab-ci.yml.

L'exécution d'une pipeline peut être déclenchée par différents événements :

  • Opération push sur un dépôt qui peut-être systématique ou avec des conditions (filtrage par branche, push sur un tag...)
  • A la main via un click dans l'interface de gitlab
  • De manière planifiée via un scheduler intégré à gitlab
  • Via l'API de gitlab

La mise en place du mécanisme est relativement simple et est détaillée dans la documentation de Gitlab. Toutefois, le nombre d'options disponibles pour les pipelines est assez élévé et la consultation d'exemples peut s'avérer rapidement nécessaire.

Dans le cadre d'une infrastructure Docker, l'idéal est de déployer les services gitlab-runner sous la forme de conteneurs Docker afin de lui donner facilement accès aux opérations sur les services déployés dans le Swarm.

Voici un exemple de fichier de déploiement de Gitlab Runner :

version: '3.9'
volumes:
  config:
  home:
  cache:

services:
  runner:
    image: gitlab/gitlab-runner:${RUNNER_VERSION:-latest}
    restart: always
    env_file:
      - formation-swarm.env
    entrypoint: "bash"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - config:/etc/gitlab-runner
      - home:/home/gitlab-runner
      - cache:/cache
    healthcheck:
      test: "gitlab-runner verify --name ${COMPOSE_PROJECT_NAME} 2>&1 | grep -q alive"
      start_period: 10s
      interval: 10s
      timeout: 10s
      retries: 10
    command: |
      -c 'set -e
          printf "\\nSetting configuration...\\n"
          mkdir -p /etc/gitlab-runner
          echo -e " log_level = \"warning\"\n concurrent = $${CONCURRENT}\n check_interval = $${CHECK_INTERVAL}\n\n [session_server]\n session_timeout = 3600 " > /etc/gitlab-runner/config.toml

          printf "\\nRegistering runner...\\n"
          gitlab-runner register --non-interactive --executor docker --docker-image docker:dind --locked=false --docker-privileged --run-untagged=${RUN_UNTAGGED:-false} --tag-list=${RUNNER_TAG_LIST:-deploy}

          printf "\\nRunning runner...\\n"
          gitlab-runner run --user=gitlab-runner --working-directory=/home/gitlab-runner'

Note :

  • le runner doit s'exécuter avec un utilisateur qui a des droits suffisants pour accéder aux fichiers du répertoire de travail pour le déploiement (ici respectivement gitlab-runner et /home/gitlab-runner).
  • le runner utilise une image "docker in docker" docker:dind afin de pouvoir exécuter des commandes docker depuis le conteneur lui-même

Et le fichier d'environnement correspondant qui définit les paramètres nécessaires à l'exécution du runner.

COMPOSE_PROJECT_NAME=fari.app-swarm
GITLAB_SERVER_URL=https://forge.uclouvain.be/
RUNNER_TOKEN=<TOKEN GENERE PAR GITLAB>
RUNNER_NAME=fari.app-swarm
API_URL=https://forge.uclouvain.be/api/v4
CI_SERVER_URL=https://forge.uclouvain.be/ci
REGISTRATION_TOKEN=<TOKEN GENERE PAR GITLAB>
CONCURRENT=-1
CHECK_INTERVAL=-1
DOCKER_VOLUMES=/var/run/docker.sock:/var/run/docker.sock
RUN_UNTAGGED=false
RUNNER_TAG_LIST=deploy

Les commandes qui seront exécutées par le runner sont définies dans un fichier .gitlab-ci.yml situé à la racine du projet concerné. Il suffit alors de déclarer un pipeline dans le projet Gitlab et de configurer ses conditions d'exécution, sa fréquence d'exécution et des variables additionelles. Si ce n'est pas fait, le runner devra être exécuté à la main via "Run pipeline".

Note : Des exemples plus complets peuvent être trouvés en ligne, par exemple sur https://gitlab.actilis.net/formation/gitlab/deploy-runner

Chaque Pipeline se caractérise par des "étages" (stages) qui seront exécutés dans l'odre de leur déclaration. Par exemple :

stages:
- prebuild
- build
- depls 
- postdeploy

On définit alors des jobs qui seront assignés à chaque étage

build-docker-image
  stage: build
  script:
  - docker compose build
deploy-on-swarm
  stage: deploy
  script:
  - docker stack deploy -c compose-swarm.yml fari

Il y a également des directives prédéfinies comme before_script ou after_script qui seront exécutées respectivement avant et après chaque exécution d'une commande script. Chaque job peut lui-mème déclarer ses propres directives before_script ou after_script.

Gitlab CI constitue donc un outil extrêmement flexible et complet pour mettre en place la livraison continue avec Docker Swarm. Les pipelines et le runner sont relativement simples à mettre en place et à configurer.


1

« Share Compose configurations between files and projects » https://docs.docker.com/compose/extends/

2

« Share Compose configurations between files and projects » https://docs.docker.com/compose/extends/

3

« Store configuration data using Docker Configs » https://docs.docker.com/engine/swarm/configs/

4

« Manage sensitive data with Docker secrets » https://docs.docker.com/engine/swarm/secrets/